123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461104621046310464104651046610467104681046910470104711047210473104741047510476104771047810479104801048110482104831048410485104861048710488104891049010491104921049310494104951049610497104981049910500105011050210503105041050510506105071050810509105101051110512105131051410515105161051710518105191052010521105221052310524105251052610527105281052910530105311053210533105341053510536105371053810539105401054110542105431054410545105461054710548105491055010551105521055310554105551055610557105581055910560105611056210563105641056510566105671056810569105701057110572105731057410575105761057710578105791058010581105821058310584105851058610587105881058910590105911059210593105941059510596105971059810599106001060110602106031060410605106061060710608106091061010611106121061310614106151061610617106181061910620106211062210623106241062510626106271062810629106301063110632106331063410635106361063710638106391064010641106421064310644106451064610647106481064910650106511065210653106541065510656106571065810659106601066110662106631066410665106661066710668106691067010671106721067310674106751067610677106781067910680106811068210683106841068510686106871068810689106901069110692106931069410695106961069710698106991070010701107021070310704107051070610707107081070910710107111071210713107141071510716107171071810719107201072110722107231072410725107261072710728107291073010731107321073310734107351073610737107381073910740107411074210743107441074510746107471074810749107501075110752107531075410755107561075710758107591076010761107621076310764107651076610767107681076910770107711077210773107741077510776107771077810779107801078110782107831078410785107861078710788107891079010791107921079310794107951079610797107981079910800108011080210803108041080510806108071080810809108101081110812108131081410815108161081710818108191082010821108221082310824108251082610827108281082910830108311083210833108341083510836108371083810839108401084110842108431084410845108461084710848108491085010851108521085310854108551085610857108581085910860108611086210863108641086510866108671086810869108701087110872108731087410875108761087710878108791088010881108821088310884108851088610887108881088910890108911089210893108941089510896108971089810899109001090110902109031090410905109061090710908109091091010911109121091310914109151091610917109181091910920109211092210923109241092510926109271092810929109301093110932109331093410935109361093710938109391094010941109421094310944109451094610947109481094910950109511095210953109541095510956109571095810959109601096110962109631096410965109661096710968109691097010971109721097310974109751097610977109781097910980109811098210983109841098510986109871098810989109901099110992109931099410995109961099710998109991100011001110021100311004110051100611007110081100911010110111101211013110141101511016110171101811019110201102111022110231102411025110261102711028110291103011031110321103311034110351103611037110381103911040110411104211043110441104511046110471104811049110501105111052110531105411055110561105711058110591106011061110621106311064110651106611067110681106911070110711107211073110741107511076110771107811079110801108111082110831108411085110861108711088110891109011091110921109311094110951109611097110981109911100111011110211103111041110511106111071110811109111101111111112111131111411115111161111711118111191112011121111221112311124111251112611127111281112911130111311113211133111341113511136111371113811139111401114111142111431114411145111461114711148111491115011151111521115311154111551115611157111581115911160111611116211163111641116511166111671116811169111701117111172111731117411175111761117711178111791118011181111821118311184111851118611187111881118911190111911119211193111941119511196111971119811199112001120111202 |
- import clsx from "clsx";
- import throttle from "lodash.throttle";
- import React, { useContext } from "react";
- import { flushSync } from "react-dom";
- import rough from "roughjs/bin/rough";
- import { nanoid } from "nanoid";
- import {
- clamp,
- pointFrom,
- pointDistance,
- vector,
- pointRotateRads,
- vectorScale,
- vectorFromPoint,
- vectorSubtract,
- vectorDot,
- vectorNormalize,
- } from "@excalidraw/math";
- import {
- COLOR_PALETTE,
- CODES,
- shouldResizeFromCenter,
- shouldMaintainAspectRatio,
- shouldRotateWithDiscreteAngle,
- isArrowKey,
- KEYS,
- APP_NAME,
- CURSOR_TYPE,
- DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
- DEFAULT_VERTICAL_ALIGN,
- DRAGGING_THRESHOLD,
- ELEMENT_SHIFT_TRANSLATE_AMOUNT,
- ELEMENT_TRANSLATE_AMOUNT,
- EVENT,
- FRAME_STYLE,
- IMAGE_MIME_TYPES,
- IMAGE_RENDER_TIMEOUT,
- isBrave,
- LINE_CONFIRM_THRESHOLD,
- MAX_ALLOWED_FILE_BYTES,
- MIME_TYPES,
- MQ_MAX_HEIGHT_LANDSCAPE,
- MQ_MAX_WIDTH_LANDSCAPE,
- MQ_MAX_WIDTH_PORTRAIT,
- MQ_RIGHT_SIDEBAR_MIN_WIDTH,
- POINTER_BUTTON,
- ROUNDNESS,
- SCROLL_TIMEOUT,
- TAP_TWICE_TIMEOUT,
- TEXT_TO_CENTER_SNAP_THRESHOLD,
- THEME,
- THEME_FILTER,
- TOUCH_CTX_MENU_TIMEOUT,
- VERTICAL_ALIGN,
- YOUTUBE_STATES,
- ZOOM_STEP,
- POINTER_EVENTS,
- TOOL_TYPE,
- isIOS,
- supportsResizeObserver,
- DEFAULT_COLLISION_THRESHOLD,
- DEFAULT_TEXT_ALIGN,
- ARROW_TYPE,
- DEFAULT_REDUCED_GLOBAL_ALPHA,
- isSafari,
- isLocalLink,
- normalizeLink,
- toValidURL,
- getGridPoint,
- getLineHeight,
- debounce,
- distance,
- getFontString,
- getNearestScrollableContainer,
- isInputLike,
- isToolIcon,
- isWritableElement,
- sceneCoordsToViewportCoords,
- tupleToCoors,
- viewportCoordsToSceneCoords,
- wrapEvent,
- updateObject,
- updateActiveTool,
- getShortcutKey,
- isTransparent,
- easeToValuesRAF,
- muteFSAbortError,
- isTestEnv,
- isDevEnv,
- easeOut,
- updateStable,
- addEventListener,
- normalizeEOL,
- getDateTime,
- isShallowEqual,
- arrayToMap,
- type EXPORT_IMAGE_TYPES,
- randomInteger,
- CLASSES,
- Emitter,
- MINIMUM_ARROW_SIZE,
- } from "@excalidraw/common";
- import {
- getObservedAppState,
- getCommonBounds,
- maybeSuggestBindingsForLinearElementAtCoords,
- getElementAbsoluteCoords,
- bindOrUnbindLinearElements,
- fixBindingsAfterDeletion,
- getHoveredElementForBinding,
- isBindingEnabled,
- shouldEnableBindingForPointerEvent,
- updateBoundElements,
- getSuggestedBindingsForArrows,
- LinearElementEditor,
- newElementWith,
- newFrameElement,
- newFreeDrawElement,
- newEmbeddableElement,
- newMagicFrameElement,
- newIframeElement,
- newArrowElement,
- newElement,
- newImageElement,
- newLinearElement,
- newTextElement,
- refreshTextDimensions,
- deepCopyElement,
- duplicateElements,
- hasBoundTextElement,
- isArrowElement,
- isBindingElement,
- isBindingElementType,
- isBoundToContainer,
- isFrameLikeElement,
- isImageElement,
- isEmbeddableElement,
- isInitializedImageElement,
- isLinearElement,
- isLinearElementType,
- isUsingAdaptiveRadius,
- isIframeElement,
- isIframeLikeElement,
- isMagicFrameElement,
- isTextBindableContainer,
- isElbowArrow,
- isFlowchartNodeElement,
- isBindableElement,
- isTextElement,
- getLockedLinearCursorAlignSize,
- getNormalizedDimensions,
- isElementCompletelyInViewport,
- isElementInViewport,
- isInvisiblySmallElement,
- getCornerRadius,
- isPathALoop,
- createSrcDoc,
- embeddableURLValidator,
- maybeParseEmbedSrc,
- getEmbedLink,
- getInitializedImageElements,
- normalizeSVG,
- updateImageCache as _updateImageCache,
- getBoundTextElement,
- getContainerCenter,
- getContainerElement,
- isValidTextContainer,
- redrawTextBoundingBox,
- shouldShowBoundingBox,
- getFrameChildren,
- isCursorInFrame,
- addElementsToFrame,
- replaceAllElementsInFrame,
- removeElementsFromFrame,
- getElementsInResizingFrame,
- getElementsInNewFrame,
- getContainingFrame,
- elementOverlapsWithFrame,
- updateFrameMembershipOfSelectedElements,
- isElementInFrame,
- getFrameLikeTitle,
- getElementsOverlappingFrame,
- filterElementsEligibleAsFrameChildren,
- hitElementBoundText,
- hitElementBoundingBoxOnly,
- hitElementItself,
- getVisibleSceneBounds,
- FlowChartCreator,
- FlowChartNavigator,
- getLinkDirectionFromKey,
- cropElement,
- wrapText,
- isElementLink,
- parseElementLinkFromURL,
- isMeasureTextSupported,
- normalizeText,
- measureText,
- getLineHeightInPx,
- getApproxMinLineWidth,
- getApproxMinLineHeight,
- getMinTextElementWidth,
- ShapeCache,
- getRenderOpacity,
- editGroupForSelectedElement,
- getElementsInGroup,
- getSelectedGroupIdForElement,
- getSelectedGroupIds,
- isElementInGroup,
- isSelectedViaGroup,
- selectGroupsForSelectedElements,
- syncInvalidIndices,
- syncMovedIndices,
- excludeElementsInFramesFromSelection,
- getSelectionStateForElements,
- makeNextSelectedElementIds,
- getResizeOffsetXY,
- getResizeArrowDirection,
- transformElements,
- getCursorForResizingElement,
- getElementWithTransformHandleType,
- getTransformHandleTypeFromCoords,
- dragNewElement,
- dragSelectedElements,
- getDragOffsetXY,
- isNonDeletedElement,
- Scene,
- Store,
- CaptureUpdateAction,
- type ElementUpdate,
- hitElementBoundingBox,
- isLineElement,
- isSimpleArrow,
- } from "@excalidraw/element";
- import type { LocalPoint, Radians } from "@excalidraw/math";
- import type {
- ExcalidrawElement,
- ExcalidrawFreeDrawElement,
- ExcalidrawGenericElement,
- ExcalidrawLinearElement,
- ExcalidrawTextElement,
- NonDeleted,
- InitializedExcalidrawImageElement,
- ExcalidrawImageElement,
- FileId,
- NonDeletedExcalidrawElement,
- ExcalidrawTextContainer,
- ExcalidrawFrameLikeElement,
- ExcalidrawMagicFrameElement,
- ExcalidrawIframeLikeElement,
- IframeData,
- ExcalidrawIframeElement,
- ExcalidrawEmbeddableElement,
- Ordered,
- MagicGenerationData,
- ExcalidrawArrowElement,
- ExcalidrawElbowArrowElement,
- } from "@excalidraw/element/types";
- import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
- import {
- actionAddToLibrary,
- actionBringForward,
- actionBringToFront,
- actionCopy,
- actionCopyAsPng,
- actionCopyAsSvg,
- copyText,
- actionCopyStyles,
- actionCut,
- actionDeleteSelected,
- actionDuplicateSelection,
- actionFinalize,
- actionFlipHorizontal,
- actionFlipVertical,
- actionGroup,
- actionPasteStyles,
- actionSelectAll,
- actionSendBackward,
- actionSendToBack,
- actionToggleGridMode,
- actionToggleStats,
- actionToggleZenMode,
- actionUnbindText,
- actionBindText,
- actionUngroup,
- actionLink,
- actionToggleElementLock,
- actionToggleLinearEditor,
- actionToggleObjectsSnapMode,
- actionToggleCropEditor,
- } from "../actions";
- import { actionWrapTextInContainer } from "../actions/actionBoundText";
- import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
- import { actionPaste } from "../actions/actionClipboard";
- import { actionCopyElementLink } from "../actions/actionElementLink";
- import { actionUnlockAllElements } from "../actions/actionElementLock";
- import {
- actionRemoveAllElementsFromFrame,
- actionSelectAllElementsInFrame,
- actionWrapSelectionInFrame,
- } from "../actions/actionFrame";
- import { createRedoAction, createUndoAction } from "../actions/actionHistory";
- import { actionTextAutoResize } from "../actions/actionTextAutoResize";
- import { actionToggleViewMode } from "../actions/actionToggleViewMode";
- import { ActionManager } from "../actions/manager";
- import { actions } from "../actions/register";
- import { getShortcutFromShortcutName } from "../actions/shortcuts";
- import { trackEvent } from "../analytics";
- import { AnimationFrameHandler } from "../animation-frame-handler";
- import {
- getDefaultAppState,
- isEraserActive,
- isHandToolActive,
- } from "../appState";
- import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
- import { exportCanvas, loadFromBlob } from "../data";
- import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
- import { restore, restoreElements } from "../data/restore";
- import { getCenter, getDistance } from "../gesture";
- import { History } from "../history";
- import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
- import {
- calculateScrollCenter,
- getElementsWithinSelection,
- getNormalizedZoom,
- getSelectedElements,
- hasBackground,
- isSomeElementSelected,
- } from "../scene";
- import { getStateForZoom } from "../scene/zoom";
- import {
- dataURLToString,
- generateIdFromFile,
- getDataURL,
- getDataURL_sync,
- getFileFromEvent,
- ImageURLToFile,
- isImageFileHandle,
- isSupportedImageFile,
- loadSceneOrLibraryFromBlob,
- normalizeFile,
- parseLibraryJSON,
- resizeImageFile,
- SVGStringToFile,
- } from "../data/blob";
- import { fileOpen } from "../data/filesystem";
- import {
- showHyperlinkTooltip,
- hideHyperlinkToolip,
- Hyperlink,
- } from "../components/hyperlink/Hyperlink";
- import { Fonts } from "../fonts";
- import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
- import { ImageSceneDataError } from "../errors";
- import {
- getSnapLinesAtPointer,
- snapDraggedElements,
- isActiveToolNonLinearSnappable,
- snapNewElement,
- snapResizingElements,
- isSnappingEnabled,
- getVisibleGaps,
- getReferenceSnapPoints,
- SnapCache,
- isGridModeEnabled,
- } from "../snapping";
- import { convertToExcalidrawElements } from "../data/transform";
- import { Renderer } from "../scene/Renderer";
- import {
- setEraserCursor,
- setCursor,
- resetCursor,
- setCursorForShape,
- } from "../cursor";
- import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
- import { LaserTrails } from "../laser-trails";
- import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
- import { textWysiwyg } from "../wysiwyg/textWysiwyg";
- import { isOverScrollBars } from "../scene/scrollbars";
- import { isMaybeMermaidDefinition } from "../mermaid";
- import { LassoTrail } from "../lasso";
- import { EraserTrail } from "../eraser";
- import ConvertElementTypePopup, {
- getConversionTypeFromElements,
- convertElementTypePopupAtom,
- convertElementTypes,
- } from "./ConvertElementTypePopup";
- import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
- import BraveMeasureTextError from "./BraveMeasureTextError";
- import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
- import { activeEyeDropperAtom } from "./EyeDropper";
- import FollowMode from "./FollowMode/FollowMode";
- import LayerUI from "./LayerUI";
- import { ElementCanvasButton } from "./MagicButton";
- import { SVGLayer } from "./SVGLayer";
- import { searchItemInFocusAtom } from "./SearchMenu";
- import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
- import { StaticCanvas, InteractiveCanvas } from "./canvases";
- import NewElementCanvas from "./canvases/NewElementCanvas";
- import {
- isPointHittingLink,
- isPointHittingLinkIcon,
- } from "./hyperlink/helpers";
- import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
- import { Toast } from "./Toast";
- import { findShapeByKey } from "./shapes";
- import UnlockPopup from "./UnlockPopup";
- import type {
- RenderInteractiveSceneCallback,
- ScrollBars,
- } from "../scene/types";
- import type { PastedMixedContent } from "../clipboard";
- import type { ExportedElements } from "../data";
- import type { ContextMenuItems } from "./ContextMenu";
- import type { FileSystemHandle } from "../data/filesystem";
- import type { ExcalidrawElementSkeleton } from "../data/transform";
- import type {
- AppClassProperties,
- AppProps,
- AppState,
- BinaryFileData,
- ExcalidrawImperativeAPI,
- BinaryFiles,
- Gesture,
- GestureEvent,
- LibraryItems,
- PointerDownState,
- SceneData,
- Device,
- FrameNameBoundsCache,
- SidebarName,
- SidebarTabName,
- KeyboardModifiersObject,
- CollaboratorPointer,
- ToolType,
- OnUserFollowedPayload,
- UnsubscribeCallback,
- EmbedsValidationStatus,
- ElementsPendingErasure,
- GenerateDiagramToCode,
- NullableGridSize,
- Offsets,
- } from "../types";
- import type { RoughCanvas } from "roughjs/bin/canvas";
- import type { Action, ActionResult } from "../actions/types";
- const AppContext = React.createContext<AppClassProperties>(null!);
- const AppPropsContext = React.createContext<AppProps>(null!);
- const deviceContextInitialValue = {
- viewport: {
- isMobile: false,
- isLandscape: false,
- },
- editor: {
- isMobile: false,
- canFitSidebar: false,
- },
- isTouchScreen: false,
- };
- const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
- DeviceContext.displayName = "DeviceContext";
- export const ExcalidrawContainerContext = React.createContext<{
- container: HTMLDivElement | null;
- id: string | null;
- }>({ container: null, id: null });
- ExcalidrawContainerContext.displayName = "ExcalidrawContainerContext";
- const ExcalidrawElementsContext = React.createContext<
- readonly NonDeletedExcalidrawElement[]
- >([]);
- ExcalidrawElementsContext.displayName = "ExcalidrawElementsContext";
- const ExcalidrawAppStateContext = React.createContext<AppState>({
- ...getDefaultAppState(),
- width: 0,
- height: 0,
- offsetLeft: 0,
- offsetTop: 0,
- });
- ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
- const ExcalidrawSetAppStateContext = React.createContext<
- React.Component<any, AppState>["setState"]
- >(() => {
- console.warn("Uninitialized ExcalidrawSetAppStateContext context!");
- });
- ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext";
- const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
- null!,
- );
- ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
- export const useApp = () => useContext(AppContext);
- export const useAppProps = () => useContext(AppPropsContext);
- export const useDevice = () => useContext<Device>(DeviceContext);
- export const useExcalidrawContainer = () =>
- useContext(ExcalidrawContainerContext);
- export const useExcalidrawElements = () =>
- useContext(ExcalidrawElementsContext);
- export const useExcalidrawAppState = () =>
- useContext(ExcalidrawAppStateContext);
- export const useExcalidrawSetAppState = () =>
- useContext(ExcalidrawSetAppStateContext);
- export const useExcalidrawActionManager = () =>
- useContext(ExcalidrawActionManagerContext);
- let didTapTwice: boolean = false;
- let tappedTwiceTimer = 0;
- let isHoldingSpace: boolean = false;
- let isPanning: boolean = false;
- let isDraggingScrollBar: boolean = false;
- let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
- let touchTimeout = 0;
- let invalidateContextMenu = false;
- /**
- * Map of youtube embed video states
- */
- const YOUTUBE_VIDEO_STATES = new Map<
- ExcalidrawElement["id"],
- ValueOf<typeof YOUTUBE_STATES>
- >();
- let IS_PLAIN_PASTE = false;
- let IS_PLAIN_PASTE_TIMER = 0;
- let PLAIN_PASTE_TOAST_SHOWN = false;
- let lastPointerUp: (() => void) | null = null;
- const gesture: Gesture = {
- pointers: new Map(),
- lastCenter: null,
- initialDistance: null,
- initialScale: null,
- };
- class App extends React.Component<AppProps, AppState> {
- canvas: AppClassProperties["canvas"];
- interactiveCanvas: AppClassProperties["interactiveCanvas"] = null;
- rc: RoughCanvas;
- unmounted: boolean = false;
- actionManager: ActionManager;
- device: Device = deviceContextInitialValue;
- private excalidrawContainerRef = React.createRef<HTMLDivElement>();
- public scene: Scene;
- public fonts: Fonts;
- public renderer: Renderer;
- public visibleElements: readonly NonDeletedExcalidrawElement[];
- private resizeObserver: ResizeObserver | undefined;
- private nearestScrollableContainer: HTMLElement | Document | undefined;
- public library: AppClassProperties["library"];
- public libraryItemsFromStorage: LibraryItems | undefined;
- public id: string;
- private store: Store;
- private history: History;
- public excalidrawContainerValue: {
- container: HTMLDivElement | null;
- id: string;
- };
- public files: BinaryFiles = {};
- public imageCache: AppClassProperties["imageCache"] = new Map();
- private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
- /**
- * Indicates whether the embeddable's url has been validated for rendering.
- * If value not set, indicates that the validation is pending.
- * Initially or on url change the flag is not reset so that we can guarantee
- * the validation came from a trusted source (the editor).
- **/
- private embedsValidationStatus: EmbedsValidationStatus = new Map();
- /** embeds that have been inserted to DOM (as a perf optim, we don't want to
- * insert to DOM before user initially scrolls to them) */
- private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
- private handleToastClose = () => {
- this.setToast(null);
- };
- private elementsPendingErasure: ElementsPendingErasure = new Set();
- public flowChartCreator: FlowChartCreator = new FlowChartCreator();
- private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
- hitLinkElement?: NonDeletedExcalidrawElement;
- lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
- lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
- null;
- lastPointerMoveEvent: PointerEvent | null = null;
- lastPointerMoveCoords: { x: number; y: number } | null = null;
- lastViewportPosition = { x: 0, y: 0 };
- animationFrameHandler = new AnimationFrameHandler();
- laserTrails = new LaserTrails(this.animationFrameHandler, this);
- eraserTrail = new EraserTrail(this.animationFrameHandler, this);
- lassoTrail = new LassoTrail(this.animationFrameHandler, this);
- onChangeEmitter = new Emitter<
- [
- elements: readonly ExcalidrawElement[],
- appState: AppState,
- files: BinaryFiles,
- ]
- >();
- onPointerDownEmitter = new Emitter<
- [
- activeTool: AppState["activeTool"],
- pointerDownState: PointerDownState,
- event: React.PointerEvent<HTMLElement>,
- ]
- >();
- onPointerUpEmitter = new Emitter<
- [
- activeTool: AppState["activeTool"],
- pointerDownState: PointerDownState,
- event: PointerEvent,
- ]
- >();
- onUserFollowEmitter = new Emitter<[payload: OnUserFollowedPayload]>();
- onScrollChangeEmitter = new Emitter<
- [scrollX: number, scrollY: number, zoom: AppState["zoom"]]
- >();
- missingPointerEventCleanupEmitter = new Emitter<
- [event: PointerEvent | null]
- >();
- onRemoveEventListenersEmitter = new Emitter<[]>();
- constructor(props: AppProps) {
- super(props);
- const defaultAppState = getDefaultAppState();
- const {
- excalidrawAPI,
- viewModeEnabled = false,
- zenModeEnabled = false,
- gridModeEnabled = false,
- objectsSnapModeEnabled = false,
- theme = defaultAppState.theme,
- name = `${t("labels.untitled")}-${getDateTime()}`,
- } = props;
- this.state = {
- ...defaultAppState,
- theme,
- isLoading: true,
- ...this.getCanvasOffsets(),
- viewModeEnabled,
- zenModeEnabled,
- objectsSnapModeEnabled,
- gridModeEnabled: gridModeEnabled ?? defaultAppState.gridModeEnabled,
- name,
- width: window.innerWidth,
- height: window.innerHeight,
- };
- this.id = nanoid();
- this.library = new Library(this);
- this.actionManager = new ActionManager(
- this.syncActionResult,
- () => this.state,
- () => this.scene.getElementsIncludingDeleted(),
- this,
- );
- this.scene = new Scene();
- this.canvas = document.createElement("canvas");
- this.rc = rough.canvas(this.canvas);
- this.renderer = new Renderer(this.scene);
- this.visibleElements = [];
- this.store = new Store(this);
- this.history = new History(this.store);
- if (excalidrawAPI) {
- const api: ExcalidrawImperativeAPI = {
- updateScene: this.updateScene,
- mutateElement: this.mutateElement,
- updateLibrary: this.library.updateLibrary,
- addFiles: this.addFiles,
- resetScene: this.resetScene,
- getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
- getSceneElementsMapIncludingDeleted:
- this.getSceneElementsMapIncludingDeleted,
- history: {
- clear: this.resetHistory,
- },
- scrollToContent: this.scrollToContent,
- getSceneElements: this.getSceneElements,
- getAppState: () => this.state,
- getFiles: () => this.files,
- getName: this.getName,
- registerAction: (action: Action) => {
- this.actionManager.registerAction(action);
- },
- refresh: this.refresh,
- setToast: this.setToast,
- id: this.id,
- setActiveTool: this.setActiveTool,
- setCursor: this.setCursor,
- resetCursor: this.resetCursor,
- updateFrameRendering: this.updateFrameRendering,
- toggleSidebar: this.toggleSidebar,
- onChange: (cb) => this.onChangeEmitter.on(cb),
- onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
- onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
- onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
- onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
- onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
- } as const;
- if (typeof excalidrawAPI === "function") {
- excalidrawAPI(api);
- } else {
- console.error("excalidrawAPI should be a function!");
- }
- }
- this.excalidrawContainerValue = {
- container: this.excalidrawContainerRef.current,
- id: this.id,
- };
- this.fonts = new Fonts(this.scene);
- this.history = new History(this.store);
- this.actionManager.registerAll(actions);
- this.actionManager.registerAction(createUndoAction(this.history));
- this.actionManager.registerAction(createRedoAction(this.history));
- }
- updateEditorAtom = <Value, Args extends unknown[], Result>(
- atom: WritableAtom<Value, Args, Result>,
- ...args: Args
- ): Result => {
- const result = editorJotaiStore.set(atom, ...args);
- this.triggerRender();
- return result;
- };
- private onWindowMessage(event: MessageEvent) {
- if (
- event.origin !== "https://player.vimeo.com" &&
- event.origin !== "https://www.youtube.com"
- ) {
- return;
- }
- let data = null;
- try {
- data = JSON.parse(event.data);
- } catch (e) {}
- if (!data) {
- return;
- }
- switch (event.origin) {
- case "https://player.vimeo.com":
- //Allowing for multiple instances of Excalidraw running in the window
- if (data.method === "paused") {
- let source: Window | null = null;
- const iframes = document.body.querySelectorAll(
- "iframe.excalidraw__embeddable",
- );
- if (!iframes) {
- break;
- }
- for (const iframe of iframes as NodeListOf<HTMLIFrameElement>) {
- if (iframe.contentWindow === event.source) {
- source = iframe.contentWindow;
- }
- }
- source?.postMessage(
- JSON.stringify({
- method: data.value ? "play" : "pause",
- value: true,
- }),
- "*",
- );
- }
- break;
- case "https://www.youtube.com":
- if (
- data.event === "infoDelivery" &&
- data.info &&
- data.id &&
- typeof data.info.playerState === "number"
- ) {
- const id = data.id;
- const playerState = data.info.playerState as number;
- if (
- (Object.values(YOUTUBE_STATES) as number[]).includes(playerState)
- ) {
- YOUTUBE_VIDEO_STATES.set(
- id,
- playerState as ValueOf<typeof YOUTUBE_STATES>,
- );
- }
- }
- break;
- }
- }
- private cacheEmbeddableRef(
- element: ExcalidrawIframeLikeElement,
- ref: HTMLIFrameElement | null,
- ) {
- if (ref) {
- this.iFrameRefs.set(element.id, ref);
- }
- }
- /**
- * Returns gridSize taking into account `gridModeEnabled`.
- * If disabled, returns null.
- */
- public getEffectiveGridSize = () => {
- return (
- isGridModeEnabled(this) ? this.state.gridSize : null
- ) as NullableGridSize;
- };
- private getHTMLIFrameElement(
- element: ExcalidrawIframeLikeElement,
- ): HTMLIFrameElement | undefined {
- return this.iFrameRefs.get(element.id);
- }
- private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) {
- if (
- this.state.activeEmbeddable?.element === element &&
- this.state.activeEmbeddable?.state === "active"
- ) {
- return;
- }
- // The delay serves two purposes
- // 1. To prevent first click propagating to iframe on mobile,
- // else the click will immediately start and stop the video
- // 2. If the user double clicks the frame center to activate it
- // without the delay youtube will immediately open the video
- // in fullscreen mode
- setTimeout(() => {
- this.setState({
- activeEmbeddable: { element, state: "active" },
- selectedElementIds: { [element.id]: true },
- newElement: null,
- selectionElement: null,
- });
- }, 100);
- if (isIframeElement(element)) {
- return;
- }
- const iframe = this.getHTMLIFrameElement(element);
- if (!iframe?.contentWindow) {
- return;
- }
- if (iframe.src.includes("youtube")) {
- const state = YOUTUBE_VIDEO_STATES.get(element.id);
- if (!state) {
- YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED);
- iframe.contentWindow.postMessage(
- JSON.stringify({
- event: "listening",
- id: element.id,
- }),
- "*",
- );
- }
- switch (state) {
- case YOUTUBE_STATES.PLAYING:
- case YOUTUBE_STATES.BUFFERING:
- iframe.contentWindow?.postMessage(
- JSON.stringify({
- event: "command",
- func: "pauseVideo",
- args: "",
- }),
- "*",
- );
- break;
- default:
- iframe.contentWindow?.postMessage(
- JSON.stringify({
- event: "command",
- func: "playVideo",
- args: "",
- }),
- "*",
- );
- }
- }
- if (iframe.src.includes("player.vimeo.com")) {
- iframe.contentWindow.postMessage(
- JSON.stringify({
- method: "paused", //video play/pause in onWindowMessage handler
- }),
- "*",
- );
- }
- }
- private isIframeLikeElementCenter(
- el: ExcalidrawIframeLikeElement | null,
- event: React.PointerEvent<HTMLElement> | PointerEvent,
- sceneX: number,
- sceneY: number,
- ) {
- return (
- el &&
- !event.altKey &&
- !event.shiftKey &&
- !event.metaKey &&
- !event.ctrlKey &&
- (this.state.activeEmbeddable?.element !== el ||
- this.state.activeEmbeddable?.state === "hover" ||
- !this.state.activeEmbeddable) &&
- sceneX >= el.x + el.width / 3 &&
- sceneX <= el.x + (2 * el.width) / 3 &&
- sceneY >= el.y + el.height / 3 &&
- sceneY <= el.y + (2 * el.height) / 3
- );
- }
- private updateEmbedValidationStatus = (
- element: ExcalidrawEmbeddableElement,
- status: boolean,
- ) => {
- this.embedsValidationStatus.set(element.id, status);
- ShapeCache.delete(element);
- };
- private updateEmbeddables = () => {
- const iframeLikes = new Set<ExcalidrawIframeLikeElement["id"]>();
- let updated = false;
- this.scene.getNonDeletedElements().filter((element) => {
- if (isEmbeddableElement(element)) {
- iframeLikes.add(element.id);
- if (!this.embedsValidationStatus.has(element.id)) {
- updated = true;
- const validated = embeddableURLValidator(
- element.link,
- this.props.validateEmbeddable,
- );
- this.updateEmbedValidationStatus(element, validated);
- }
- } else if (isIframeElement(element)) {
- iframeLikes.add(element.id);
- }
- return false;
- });
- if (updated) {
- this.scene.triggerUpdate();
- }
- // GC
- this.iFrameRefs.forEach((ref, id) => {
- if (!iframeLikes.has(id)) {
- this.iFrameRefs.delete(id);
- }
- });
- };
- private renderEmbeddables() {
- const scale = this.state.zoom.value;
- const normalizedWidth = this.state.width;
- const normalizedHeight = this.state.height;
- const embeddableElements = this.scene
- .getNonDeletedElements()
- .filter(
- (el): el is Ordered<NonDeleted<ExcalidrawIframeLikeElement>> =>
- (isEmbeddableElement(el) &&
- this.embedsValidationStatus.get(el.id) === true) ||
- isIframeElement(el),
- );
- return (
- <>
- {embeddableElements.map((el) => {
- const { x, y } = sceneCoordsToViewportCoords(
- { sceneX: el.x, sceneY: el.y },
- this.state,
- );
- const isVisible = isElementInViewport(
- el,
- normalizedWidth,
- normalizedHeight,
- this.state,
- this.scene.getNonDeletedElementsMap(),
- );
- const hasBeenInitialized = this.initializedEmbeds.has(el.id);
- if (isVisible && !hasBeenInitialized) {
- this.initializedEmbeds.add(el.id);
- }
- const shouldRender = isVisible || hasBeenInitialized;
- if (!shouldRender) {
- return null;
- }
- let src: IframeData | null;
- if (isIframeElement(el)) {
- src = null;
- const data: MagicGenerationData = (el.customData?.generationData ??
- this.magicGenerations.get(el.id)) || {
- status: "error",
- message: "No generation data",
- code: "ERR_NO_GENERATION_DATA",
- };
- if (data.status === "done") {
- const html = data.html;
- src = {
- intrinsicSize: { w: el.width, h: el.height },
- type: "document",
- srcdoc: () => {
- return html;
- },
- } as const;
- } else if (data.status === "pending") {
- src = {
- intrinsicSize: { w: el.width, h: el.height },
- type: "document",
- srcdoc: () => {
- return createSrcDoc(`
- <style>
- html, body {
- width: 100%;
- height: 100%;
- color: ${
- this.state.theme === THEME.DARK ? "white" : "black"
- };
- }
- body {
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: column;
- gap: 1rem;
- }
- .Spinner {
- display: flex;
- align-items: center;
- justify-content: center;
- margin-left: auto;
- margin-right: auto;
- }
- .Spinner svg {
- animation: rotate 1.6s linear infinite;
- transform-origin: center center;
- width: 40px;
- height: 40px;
- }
- .Spinner circle {
- stroke: currentColor;
- animation: dash 1.6s linear 0s infinite;
- stroke-linecap: round;
- }
- @keyframes rotate {
- 100% {
- transform: rotate(360deg);
- }
- }
- @keyframes dash {
- 0% {
- stroke-dasharray: 1, 300;
- stroke-dashoffset: 0;
- }
- 50% {
- stroke-dasharray: 150, 300;
- stroke-dashoffset: -200;
- }
- 100% {
- stroke-dasharray: 1, 300;
- stroke-dashoffset: -280;
- }
- }
- </style>
- <div class="Spinner">
- <svg
- viewBox="0 0 100 100"
- >
- <circle
- cx="50"
- cy="50"
- r="46"
- stroke-width="8"
- fill="none"
- stroke-miter-limit="10"
- />
- </svg>
- </div>
- <div>Generating...</div>
- `);
- },
- } as const;
- } else {
- let message: string;
- if (data.code === "ERR_GENERATION_INTERRUPTED") {
- message = "Generation was interrupted...";
- } else {
- message = data.message || "Generation failed";
- }
- src = {
- intrinsicSize: { w: el.width, h: el.height },
- type: "document",
- srcdoc: () => {
- return createSrcDoc(`
- <style>
- html, body {
- height: 100%;
- }
- body {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- color: ${COLOR_PALETTE.red[3]};
- }
- h1, h3 {
- margin-top: 0;
- margin-bottom: 0.5rem;
- }
- </style>
- <h1>Error!</h1>
- <h3>${message}</h3>
- `);
- },
- } as const;
- }
- } else {
- src = getEmbedLink(toValidURL(el.link || ""));
- }
- const isActive =
- this.state.activeEmbeddable?.element === el &&
- this.state.activeEmbeddable?.state === "active";
- const isHovered =
- this.state.activeEmbeddable?.element === el &&
- this.state.activeEmbeddable?.state === "hover";
- return (
- <div
- key={el.id}
- className={clsx("excalidraw__embeddable-container", {
- "is-hovered": isHovered,
- })}
- style={{
- transform: isVisible
- ? `translate(${x - this.state.offsetLeft}px, ${
- y - this.state.offsetTop
- }px) scale(${scale})`
- : "none",
- display: isVisible ? "block" : "none",
- opacity: getRenderOpacity(
- el,
- getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
- this.elementsPendingErasure,
- null,
- this.state.openDialog?.name === "elementLinkSelector"
- ? DEFAULT_REDUCED_GLOBAL_ALPHA
- : 1,
- ),
- ["--embeddable-radius" as string]: `${getCornerRadius(
- Math.min(el.width, el.height),
- el,
- )}px`,
- }}
- >
- <div
- //this is a hack that addresses isse with embedded excalidraw.com embeddable
- //https://github.com/excalidraw/excalidraw/pull/6691#issuecomment-1607383938
- /*ref={(ref) => {
- if (!this.excalidrawContainerRef.current) {
- return;
- }
- const container = this.excalidrawContainerRef.current;
- const sh = container.scrollHeight;
- const ch = container.clientHeight;
- if (sh !== ch) {
- container.style.height = `${sh}px`;
- setTimeout(() => {
- container.style.height = `100%`;
- });
- }
- }}*/
- className="excalidraw__embeddable-container__inner"
- style={{
- width: isVisible ? `${el.width}px` : 0,
- height: isVisible ? `${el.height}px` : 0,
- transform: isVisible ? `rotate(${el.angle}rad)` : "none",
- pointerEvents: isActive
- ? POINTER_EVENTS.enabled
- : POINTER_EVENTS.disabled,
- }}
- >
- {isHovered && (
- <div className="excalidraw__embeddable-hint">
- {t("buttons.embeddableInteractionButton")}
- </div>
- )}
- <div
- className="excalidraw__embeddable__outer"
- style={{
- padding: `${el.strokeWidth}px`,
- }}
- >
- {(isEmbeddableElement(el)
- ? this.props.renderEmbeddable?.(el, this.state)
- : null) ?? (
- <iframe
- ref={(ref) => this.cacheEmbeddableRef(el, ref)}
- className="excalidraw__embeddable"
- srcDoc={
- src?.type === "document"
- ? src.srcdoc(this.state.theme)
- : undefined
- }
- src={
- src?.type !== "document" ? src?.link ?? "" : undefined
- }
- // https://stackoverflow.com/q/18470015
- scrolling="no"
- referrerPolicy="no-referrer-when-downgrade"
- title="Excalidraw Embedded Content"
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
- allowFullScreen={true}
- sandbox={`${
- src?.sandbox?.allowSameOrigin ? "allow-same-origin" : ""
- } allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`}
- />
- )}
- </div>
- </div>
- </div>
- );
- })}
- </>
- );
- }
- private getFrameNameDOMId = (frameElement: ExcalidrawElement) => {
- return `${this.id}-frame-name-${frameElement.id}`;
- };
- frameNameBoundsCache: FrameNameBoundsCache = {
- get: (frameElement) => {
- let bounds = this.frameNameBoundsCache._cache.get(frameElement.id);
- if (
- !bounds ||
- bounds.zoom !== this.state.zoom.value ||
- bounds.versionNonce !== frameElement.versionNonce
- ) {
- const frameNameDiv = document.getElementById(
- this.getFrameNameDOMId(frameElement),
- );
- if (frameNameDiv) {
- const box = frameNameDiv.getBoundingClientRect();
- const boxSceneTopLeft = viewportCoordsToSceneCoords(
- { clientX: box.x, clientY: box.y },
- this.state,
- );
- const boxSceneBottomRight = viewportCoordsToSceneCoords(
- { clientX: box.right, clientY: box.bottom },
- this.state,
- );
- bounds = {
- x: boxSceneTopLeft.x,
- y: boxSceneTopLeft.y,
- width: boxSceneBottomRight.x - boxSceneTopLeft.x,
- height: boxSceneBottomRight.y - boxSceneTopLeft.y,
- angle: 0,
- zoom: this.state.zoom.value,
- versionNonce: frameElement.versionNonce,
- };
- this.frameNameBoundsCache._cache.set(frameElement.id, bounds);
- return bounds;
- }
- return null;
- }
- return bounds;
- },
- /**
- * @private
- */
- _cache: new Map(),
- };
- private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => {
- if (frame) {
- this.scene.mutateElement(frame, { name: frame.name?.trim() || null });
- }
- this.setState({ editingFrame: null });
- };
- private renderFrameNames = () => {
- if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) {
- if (this.state.editingFrame) {
- this.resetEditingFrame(null);
- }
- return null;
- }
- const isDarkTheme = this.state.theme === THEME.DARK;
- const nonDeletedFramesLikes = this.scene.getNonDeletedFramesLikes();
- const focusedSearchMatch =
- nonDeletedFramesLikes.length > 0
- ? this.state.searchMatches?.focusedId &&
- isFrameLikeElement(
- this.scene.getElement(this.state.searchMatches.focusedId),
- )
- ? this.state.searchMatches.matches.find((sm) => sm.focus)
- : null
- : null;
- return nonDeletedFramesLikes.map((f) => {
- if (
- !isElementInViewport(
- f,
- this.canvas.width / window.devicePixelRatio,
- this.canvas.height / window.devicePixelRatio,
- {
- offsetLeft: this.state.offsetLeft,
- offsetTop: this.state.offsetTop,
- scrollX: this.state.scrollX,
- scrollY: this.state.scrollY,
- zoom: this.state.zoom,
- },
- this.scene.getNonDeletedElementsMap(),
- )
- ) {
- if (this.state.editingFrame === f.id) {
- this.resetEditingFrame(f);
- }
- // if frame not visible, don't render its name
- return null;
- }
- const { x: x1, y: y1 } = sceneCoordsToViewportCoords(
- { sceneX: f.x, sceneY: f.y },
- this.state,
- );
- const FRAME_NAME_EDIT_PADDING = 6;
- let frameNameJSX;
- const frameName = getFrameLikeTitle(f);
- if (f.id === this.state.editingFrame) {
- const frameNameInEdit = frameName;
- frameNameJSX = (
- <input
- autoFocus
- value={frameNameInEdit}
- onChange={(e) => {
- this.scene.mutateElement(f, {
- name: e.target.value,
- });
- }}
- onFocus={(e) => e.target.select()}
- onBlur={() => this.resetEditingFrame(f)}
- onKeyDown={(event) => {
- // for some inexplicable reason, `onBlur` triggered on ESC
- // does not reset `state.editingFrame` despite being called,
- // and we need to reset it here as well
- if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
- this.resetEditingFrame(f);
- }
- }}
- style={{
- background: this.state.viewBackgroundColor,
- filter: isDarkTheme ? THEME_FILTER : "none",
- zIndex: 2,
- border: "none",
- display: "block",
- padding: `${FRAME_NAME_EDIT_PADDING}px`,
- borderRadius: 4,
- boxShadow: "inset 0 0 0 1px var(--color-primary)",
- fontFamily: "Assistant",
- fontSize: `${FRAME_STYLE.nameFontSize}px`,
- transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
- color: "var(--color-gray-80)",
- overflow: "hidden",
- maxWidth: `${
- document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
- }px`,
- }}
- size={frameNameInEdit.length + 1 || 1}
- dir="auto"
- autoComplete="off"
- autoCapitalize="off"
- autoCorrect="off"
- />
- );
- } else {
- frameNameJSX = frameName;
- }
- return (
- <div
- id={this.getFrameNameDOMId(f)}
- key={f.id}
- style={{
- position: "absolute",
- // Positioning from bottom so that we don't to either
- // calculate text height or adjust using transform (which)
- // messes up input position when editing the frame name.
- // This makes the positioning deterministic and we can calculate
- // the same position when rendering to canvas / svg.
- bottom: `${
- this.state.height +
- FRAME_STYLE.nameOffsetY -
- y1 +
- this.state.offsetTop
- }px`,
- left: `${x1 - this.state.offsetLeft}px`,
- zIndex: 2,
- fontSize: FRAME_STYLE.nameFontSize,
- color: isDarkTheme
- ? FRAME_STYLE.nameColorDarkTheme
- : FRAME_STYLE.nameColorLightTheme,
- lineHeight: FRAME_STYLE.nameLineHeight,
- width: "max-content",
- maxWidth:
- focusedSearchMatch?.id === f.id && focusedSearchMatch?.focus
- ? "none"
- : `${f.width * this.state.zoom.value}px`,
- overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
- whiteSpace: "nowrap",
- textOverflow: "ellipsis",
- cursor: CURSOR_TYPE.MOVE,
- pointerEvents: this.state.viewModeEnabled
- ? POINTER_EVENTS.disabled
- : POINTER_EVENTS.enabled,
- }}
- onPointerDown={(event) => this.handleCanvasPointerDown(event)}
- onWheel={(event) => this.handleWheel(event)}
- onContextMenu={this.handleCanvasContextMenu}
- onDoubleClick={() => {
- this.setState({
- editingFrame: f.id,
- });
- }}
- >
- {frameNameJSX}
- </div>
- );
- });
- };
- private toggleOverscrollBehavior(event: React.PointerEvent) {
- // when pointer inside editor, disable overscroll behavior to prevent
- // panning to trigger history back/forward on MacOS Chrome
- document.documentElement.style.overscrollBehaviorX =
- event.type === "pointerenter" ? "none" : "auto";
- }
- public render() {
- const selectedElements = this.scene.getSelectedElements(this.state);
- const { renderTopRightUI, renderCustomStats } = this.props;
- const sceneNonce = this.scene.getSceneNonce();
- const { elementsMap, visibleElements } =
- this.renderer.getRenderableElements({
- sceneNonce,
- zoom: this.state.zoom,
- offsetLeft: this.state.offsetLeft,
- offsetTop: this.state.offsetTop,
- scrollX: this.state.scrollX,
- scrollY: this.state.scrollY,
- height: this.state.height,
- width: this.state.width,
- editingTextElement: this.state.editingTextElement,
- newElementId: this.state.newElement?.id,
- });
- this.visibleElements = visibleElements;
- const allElementsMap = this.scene.getNonDeletedElementsMap();
- const shouldBlockPointerEvents =
- // default back to `--ui-pointerEvents` flow if setPointerCapture
- // not supported
- "setPointerCapture" in HTMLElement.prototype
- ? false
- : this.state.selectionElement ||
- this.state.newElement ||
- this.state.selectedElementsAreBeingDragged ||
- this.state.resizingElement ||
- (this.state.activeTool.type === "laser" &&
- // technically we can just test on this once we make it more safe
- this.state.cursorButton === "down");
- const firstSelectedElement = selectedElements[0];
- const showShapeSwitchPanel =
- editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel";
- return (
- <div
- className={clsx("excalidraw excalidraw-container", {
- "excalidraw--view-mode":
- this.state.viewModeEnabled ||
- this.state.openDialog?.name === "elementLinkSelector",
- "excalidraw--mobile": this.device.editor.isMobile,
- })}
- style={{
- ["--ui-pointerEvents" as any]: shouldBlockPointerEvents
- ? POINTER_EVENTS.disabled
- : POINTER_EVENTS.enabled,
- ["--right-sidebar-width" as any]: "302px",
- }}
- ref={this.excalidrawContainerRef}
- onDrop={this.handleAppOnDrop}
- tabIndex={0}
- onKeyDown={
- this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
- }
- onPointerEnter={this.toggleOverscrollBehavior}
- onPointerLeave={this.toggleOverscrollBehavior}
- >
- <AppContext.Provider value={this}>
- <AppPropsContext.Provider value={this.props}>
- <ExcalidrawContainerContext.Provider
- value={this.excalidrawContainerValue}
- >
- <DeviceContext.Provider value={this.device}>
- <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
- <ExcalidrawAppStateContext.Provider value={this.state}>
- <ExcalidrawElementsContext.Provider
- value={this.scene.getNonDeletedElements()}
- >
- <ExcalidrawActionManagerContext.Provider
- value={this.actionManager}
- >
- <LayerUI
- canvas={this.canvas}
- appState={this.state}
- files={this.files}
- setAppState={this.setAppState}
- actionManager={this.actionManager}
- elements={this.scene.getNonDeletedElements()}
- onLockToggle={this.toggleLock}
- onPenModeToggle={this.togglePenMode}
- onHandToolToggle={this.onHandToolToggle}
- langCode={getLanguage().code}
- renderTopRightUI={renderTopRightUI}
- renderCustomStats={renderCustomStats}
- showExitZenModeBtn={
- typeof this.props?.zenModeEnabled === "undefined" &&
- this.state.zenModeEnabled
- }
- UIOptions={this.props.UIOptions}
- onExportImage={this.onExportImage}
- renderWelcomeScreen={
- !this.state.isLoading &&
- this.state.showWelcomeScreen &&
- this.state.activeTool.type === "selection" &&
- !this.state.zenModeEnabled &&
- !this.scene.getElementsIncludingDeleted().length
- }
- app={this}
- isCollaborating={this.props.isCollaborating}
- generateLinkForSelection={
- this.props.generateLinkForSelection
- }
- >
- {this.props.children}
- </LayerUI>
- <div className="excalidraw-textEditorContainer" />
- <div className="excalidraw-contextMenuContainer" />
- <div className="excalidraw-eye-dropper-container" />
- <SVGLayer
- trails={[
- this.laserTrails,
- this.lassoTrail,
- this.eraserTrail,
- ]}
- />
- {selectedElements.length === 1 &&
- this.state.openDialog?.name !==
- "elementLinkSelector" &&
- this.state.showHyperlinkPopup && (
- <Hyperlink
- key={firstSelectedElement.id}
- element={firstSelectedElement}
- scene={this.scene}
- setAppState={this.setAppState}
- onLinkOpen={this.props.onLinkOpen}
- setToast={this.setToast}
- updateEmbedValidationStatus={
- this.updateEmbedValidationStatus
- }
- />
- )}
- {this.props.aiEnabled !== false &&
- selectedElements.length === 1 &&
- isMagicFrameElement(firstSelectedElement) && (
- <ElementCanvasButtons
- element={firstSelectedElement}
- elementsMap={elementsMap}
- >
- <ElementCanvasButton
- title={t("labels.convertToCode")}
- icon={MagicIcon}
- checked={false}
- onChange={() =>
- this.onMagicFrameGenerate(
- firstSelectedElement,
- "button",
- )
- }
- />
- </ElementCanvasButtons>
- )}
- {selectedElements.length === 1 &&
- isIframeElement(firstSelectedElement) &&
- firstSelectedElement.customData?.generationData
- ?.status === "done" && (
- <ElementCanvasButtons
- element={firstSelectedElement}
- elementsMap={elementsMap}
- >
- <ElementCanvasButton
- title={t("labels.copySource")}
- icon={copyIcon}
- checked={false}
- onChange={() =>
- this.onIframeSrcCopy(firstSelectedElement)
- }
- />
- <ElementCanvasButton
- title="Enter fullscreen"
- icon={fullscreenIcon}
- checked={false}
- onChange={() => {
- const iframe =
- this.getHTMLIFrameElement(
- firstSelectedElement,
- );
- if (iframe) {
- try {
- iframe.requestFullscreen();
- this.setState({
- activeEmbeddable: {
- element: firstSelectedElement,
- state: "active",
- },
- selectedElementIds: {
- [firstSelectedElement.id]: true,
- },
- newElement: null,
- selectionElement: null,
- });
- } catch (err: any) {
- console.warn(err);
- this.setState({
- errorMessage:
- "Couldn't enter fullscreen",
- });
- }
- }
- }}
- />
- </ElementCanvasButtons>
- )}
- {this.state.toast !== null && (
- <Toast
- message={this.state.toast.message}
- onClose={this.handleToastClose}
- duration={this.state.toast.duration}
- closable={this.state.toast.closable}
- />
- )}
- {this.state.contextMenu && (
- <ContextMenu
- items={this.state.contextMenu.items}
- top={this.state.contextMenu.top}
- left={this.state.contextMenu.left}
- actionManager={this.actionManager}
- onClose={(callback) => {
- this.setState({ contextMenu: null }, () => {
- this.focusContainer();
- callback?.();
- });
- }}
- />
- )}
- <StaticCanvas
- canvas={this.canvas}
- rc={this.rc}
- elementsMap={elementsMap}
- allElementsMap={allElementsMap}
- visibleElements={visibleElements}
- sceneNonce={sceneNonce}
- selectionNonce={
- this.state.selectionElement?.versionNonce
- }
- scale={window.devicePixelRatio}
- appState={this.state}
- renderConfig={{
- imageCache: this.imageCache,
- isExporting: false,
- renderGrid: isGridModeEnabled(this),
- canvasBackgroundColor:
- this.state.viewBackgroundColor,
- embedsValidationStatus: this.embedsValidationStatus,
- elementsPendingErasure: this.elementsPendingErasure,
- pendingFlowchartNodes:
- this.flowChartCreator.pendingNodes,
- }}
- />
- {this.state.newElement && (
- <NewElementCanvas
- appState={this.state}
- scale={window.devicePixelRatio}
- rc={this.rc}
- elementsMap={elementsMap}
- allElementsMap={allElementsMap}
- renderConfig={{
- imageCache: this.imageCache,
- isExporting: false,
- renderGrid: false,
- canvasBackgroundColor:
- this.state.viewBackgroundColor,
- embedsValidationStatus:
- this.embedsValidationStatus,
- elementsPendingErasure:
- this.elementsPendingErasure,
- pendingFlowchartNodes: null,
- }}
- />
- )}
- <InteractiveCanvas
- containerRef={this.excalidrawContainerRef}
- canvas={this.interactiveCanvas}
- elementsMap={elementsMap}
- visibleElements={visibleElements}
- allElementsMap={allElementsMap}
- selectedElements={selectedElements}
- sceneNonce={sceneNonce}
- selectionNonce={
- this.state.selectionElement?.versionNonce
- }
- scale={window.devicePixelRatio}
- appState={this.state}
- renderScrollbars={
- this.props.renderScrollbars === true
- }
- device={this.device}
- renderInteractiveSceneCallback={
- this.renderInteractiveSceneCallback
- }
- handleCanvasRef={this.handleInteractiveCanvasRef}
- onContextMenu={this.handleCanvasContextMenu}
- onPointerMove={this.handleCanvasPointerMove}
- onPointerUp={this.handleCanvasPointerUp}
- onPointerCancel={this.removePointer}
- onTouchMove={this.handleTouchMove}
- onPointerDown={this.handleCanvasPointerDown}
- onDoubleClick={this.handleCanvasDoubleClick}
- />
- {this.state.userToFollow && (
- <FollowMode
- width={this.state.width}
- height={this.state.height}
- userToFollow={this.state.userToFollow}
- onDisconnect={this.maybeUnfollowRemoteUser}
- />
- )}
- {this.renderFrameNames()}
- {this.state.activeLockedId && (
- <UnlockPopup
- app={this}
- activeLockedId={this.state.activeLockedId}
- />
- )}
- {showShapeSwitchPanel && (
- <ConvertElementTypePopup app={this} />
- )}
- </ExcalidrawActionManagerContext.Provider>
- {this.renderEmbeddables()}
- </ExcalidrawElementsContext.Provider>
- </ExcalidrawAppStateContext.Provider>
- </ExcalidrawSetAppStateContext.Provider>
- </DeviceContext.Provider>
- </ExcalidrawContainerContext.Provider>
- </AppPropsContext.Provider>
- </AppContext.Provider>
- </div>
- );
- }
- public focusContainer: AppClassProperties["focusContainer"] = () => {
- this.excalidrawContainerRef.current?.focus();
- };
- public getSceneElementsIncludingDeleted = () => {
- return this.scene.getElementsIncludingDeleted();
- };
- public getSceneElementsMapIncludingDeleted = () => {
- return this.scene.getElementsMapIncludingDeleted();
- };
- public getSceneElements = () => {
- return this.scene.getNonDeletedElements();
- };
- public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
- this.addElementsFromPasteOrLibrary({
- elements,
- position: "center",
- files: null,
- });
- };
- public onExportImage = async (
- type: keyof typeof EXPORT_IMAGE_TYPES,
- elements: ExportedElements,
- opts: { exportingFrame: ExcalidrawFrameLikeElement | null },
- ) => {
- trackEvent("export", type, "ui");
- const fileHandle = await exportCanvas(
- type,
- elements,
- this.state,
- this.files,
- {
- exportBackground: this.state.exportBackground,
- name: this.getName(),
- viewBackgroundColor: this.state.viewBackgroundColor,
- exportingFrame: opts.exportingFrame,
- },
- )
- .catch(muteFSAbortError)
- .catch((error) => {
- console.error(error);
- this.setState({ errorMessage: error.message });
- });
- if (
- this.state.exportEmbedScene &&
- fileHandle &&
- isImageFileHandle(fileHandle)
- ) {
- this.setState({ fileHandle });
- }
- };
- private magicGenerations = new Map<
- ExcalidrawIframeElement["id"],
- MagicGenerationData
- >();
- private updateMagicGeneration = ({
- frameElement,
- data,
- }: {
- frameElement: ExcalidrawIframeElement;
- data: MagicGenerationData;
- }) => {
- if (data.status === "pending") {
- // We don't wanna persist pending state to storage. It should be in-app
- // state only.
- // Thus reset so that we prefer local cache (if there was some
- // generationData set previously)
- this.scene.mutateElement(
- frameElement,
- {
- customData: { generationData: undefined },
- },
- { informMutation: false, isDragging: false },
- );
- } else {
- this.scene.mutateElement(
- frameElement,
- {
- customData: { generationData: data },
- },
- { informMutation: false, isDragging: false },
- );
- }
- this.magicGenerations.set(frameElement.id, data);
- this.triggerRender();
- };
- public plugins: {
- diagramToCode?: {
- generate: GenerateDiagramToCode;
- };
- } = {};
- public setPlugins(plugins: Partial<App["plugins"]>) {
- Object.assign(this.plugins, plugins);
- }
- private async onMagicFrameGenerate(
- magicFrame: ExcalidrawMagicFrameElement,
- source: "button" | "upstream",
- ) {
- const generateDiagramToCode = this.plugins.diagramToCode?.generate;
- if (!generateDiagramToCode) {
- this.setState({
- errorMessage: "No diagram to code plugin found",
- });
- return;
- }
- const magicFrameChildren = getElementsOverlappingFrame(
- this.scene.getNonDeletedElements(),
- magicFrame,
- ).filter((el) => !isMagicFrameElement(el));
- if (!magicFrameChildren.length) {
- if (source === "button") {
- this.setState({ errorMessage: "Cannot generate from an empty frame" });
- trackEvent("ai", "generate (no-children)", "d2c");
- } else {
- this.setActiveTool({ type: "magicframe" });
- }
- return;
- }
- const frameElement = this.insertIframeElement({
- sceneX: magicFrame.x + magicFrame.width + 30,
- sceneY: magicFrame.y,
- width: magicFrame.width,
- height: magicFrame.height,
- });
- if (!frameElement) {
- return;
- }
- this.updateMagicGeneration({
- frameElement,
- data: { status: "pending" },
- });
- this.setState({
- selectedElementIds: { [frameElement.id]: true },
- });
- trackEvent("ai", "generate (start)", "d2c");
- try {
- const { html } = await generateDiagramToCode({
- frame: magicFrame,
- children: magicFrameChildren,
- });
- trackEvent("ai", "generate (success)", "d2c");
- if (!html.trim()) {
- this.updateMagicGeneration({
- frameElement,
- data: {
- status: "error",
- code: "ERR_OAI",
- message: "Nothing genereated :(",
- },
- });
- return;
- }
- const parsedHtml =
- html.includes("<!DOCTYPE html>") && html.includes("</html>")
- ? html.slice(
- html.indexOf("<!DOCTYPE html>"),
- html.indexOf("</html>") + "</html>".length,
- )
- : html;
- this.updateMagicGeneration({
- frameElement,
- data: { status: "done", html: parsedHtml },
- });
- } catch (error: any) {
- trackEvent("ai", "generate (failed)", "d2c");
- this.updateMagicGeneration({
- frameElement,
- data: {
- status: "error",
- code: "ERR_OAI",
- message: error.message || "Unknown error during generation",
- },
- });
- }
- }
- private onIframeSrcCopy(element: ExcalidrawIframeElement) {
- if (element.customData?.generationData?.status === "done") {
- copyTextToSystemClipboard(element.customData.generationData.html);
- this.setToast({
- message: "copied to clipboard",
- closable: false,
- duration: 1500,
- });
- }
- }
- public onMagicframeToolSelect = () => {
- const selectedElements = this.scene.getSelectedElements({
- selectedElementIds: this.state.selectedElementIds,
- });
- if (selectedElements.length === 0) {
- this.setActiveTool({ type: TOOL_TYPE.magicframe });
- trackEvent("ai", "tool-select (empty-selection)", "d2c");
- } else {
- const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
- selectedElements.length === 1 &&
- isMagicFrameElement(selectedElements[0]) &&
- selectedElements[0];
- // case: user selected elements containing frame-like(s) or are frame
- // members, we don't want to wrap into another magicframe
- // (unless the only selected element is a magic frame which we reuse)
- if (
- !selectedMagicFrame &&
- selectedElements.some((el) => isFrameLikeElement(el) || el.frameId)
- ) {
- this.setActiveTool({ type: TOOL_TYPE.magicframe });
- return;
- }
- trackEvent("ai", "tool-select (existing selection)", "d2c");
- let frame: ExcalidrawMagicFrameElement;
- if (selectedMagicFrame) {
- // a single magicframe already selected -> use it
- frame = selectedMagicFrame;
- } else {
- // selected elements aren't wrapped in magic frame yet -> wrap now
- const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
- const padding = 50;
- frame = newMagicFrameElement({
- ...FRAME_STYLE,
- x: minX - padding,
- y: minY - padding,
- width: maxX - minX + padding * 2,
- height: maxY - minY + padding * 2,
- opacity: 100,
- locked: false,
- });
- this.scene.insertElement(frame);
- for (const child of selectedElements) {
- this.scene.mutateElement(child, { frameId: frame.id });
- }
- this.setState({
- selectedElementIds: { [frame.id]: true },
- });
- }
- this.onMagicFrameGenerate(frame, "upstream");
- }
- };
- private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
- this.updateEditorAtom(activeEyeDropperAtom, {
- swapPreviewOnAlt: true,
- colorPickerType:
- type === "stroke" ? "elementStroke" : "elementBackground",
- onSelect: (color, event) => {
- const shouldUpdateStrokeColor =
- (type === "background" && event.altKey) ||
- (type === "stroke" && !event.altKey);
- const selectedElements = this.scene.getSelectedElements(this.state);
- if (
- !selectedElements.length ||
- this.state.activeTool.type !== "selection"
- ) {
- if (shouldUpdateStrokeColor) {
- this.syncActionResult({
- appState: { ...this.state, currentItemStrokeColor: color },
- captureUpdate: CaptureUpdateAction.IMMEDIATELY,
- });
- } else {
- this.syncActionResult({
- appState: { ...this.state, currentItemBackgroundColor: color },
- captureUpdate: CaptureUpdateAction.IMMEDIATELY,
- });
- }
- } else {
- this.updateScene({
- elements: this.scene.getElementsIncludingDeleted().map((el) => {
- if (this.state.selectedElementIds[el.id]) {
- return newElementWith(el, {
- [shouldUpdateStrokeColor ? "strokeColor" : "backgroundColor"]:
- color,
- });
- }
- return el;
- }),
- captureUpdate: CaptureUpdateAction.IMMEDIATELY,
- });
- }
- },
- keepOpenOnAlt: false,
- });
- };
- public dismissLinearEditor = () => {
- setTimeout(() => {
- if (this.state.selectedLinearElement?.isEditing) {
- this.setState({
- selectedLinearElement: {
- ...this.state.selectedLinearElement,
- isEditing: false,
- },
- });
- }
- });
- };
- public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
- if (this.unmounted || actionResult === false) {
- return;
- }
- this.store.scheduleAction(actionResult.captureUpdate);
- let didUpdate = false;
- let editingTextElement: AppState["editingTextElement"] | null = null;
- if (actionResult.elements) {
- this.scene.replaceAllElements(actionResult.elements);
- didUpdate = true;
- }
- if (actionResult.files) {
- this.addMissingFiles(actionResult.files, actionResult.replaceFiles);
- this.addNewImagesToImageCache();
- }
- if (actionResult.appState || editingTextElement || this.state.contextMenu) {
- let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
- let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
- const theme =
- actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
- const name = actionResult?.appState?.name ?? this.state.name;
- const errorMessage =
- actionResult?.appState?.errorMessage ?? this.state.errorMessage;
- if (typeof this.props.viewModeEnabled !== "undefined") {
- viewModeEnabled = this.props.viewModeEnabled;
- }
- if (typeof this.props.zenModeEnabled !== "undefined") {
- zenModeEnabled = this.props.zenModeEnabled;
- }
- editingTextElement = actionResult.appState?.editingTextElement || null;
- // make sure editingTextElement points to latest element reference
- if (actionResult.elements && editingTextElement) {
- actionResult.elements.forEach((element) => {
- if (
- editingTextElement?.id === element.id &&
- editingTextElement !== element &&
- isNonDeletedElement(element) &&
- isTextElement(element)
- ) {
- editingTextElement = element;
- }
- });
- }
- if (editingTextElement?.isDeleted) {
- editingTextElement = null;
- }
- this.setState((prevAppState) => {
- const actionAppState = actionResult.appState || {};
- return {
- ...prevAppState,
- ...actionAppState,
- // NOTE this will prevent opening context menu using an action
- // or programmatically from the host, so it will need to be
- // rewritten later
- contextMenu: null,
- editingTextElement,
- viewModeEnabled,
- zenModeEnabled,
- theme,
- name,
- errorMessage,
- };
- });
- didUpdate = true;
- }
- if (!didUpdate) {
- this.scene.triggerUpdate();
- }
- });
- // Lifecycle
- private onBlur = withBatchedUpdates(() => {
- isHoldingSpace = false;
- this.setState({ isBindingEnabled: true });
- });
- private onUnload = () => {
- this.onBlur();
- };
- private disableEvent: EventListener = (event) => {
- event.preventDefault();
- };
- private resetHistory = () => {
- this.history.clear();
- };
- private resetStore = () => {
- this.store.clear();
- };
- /**
- * Resets scene & history.
- * ! Do not use to clear scene user action !
- */
- private resetScene = withBatchedUpdates(
- (opts?: { resetLoadingState: boolean }) => {
- this.scene.replaceAllElements([]);
- this.setState((state) => ({
- ...getDefaultAppState(),
- isLoading: opts?.resetLoadingState ? false : state.isLoading,
- theme: this.state.theme,
- }));
- this.resetStore();
- this.resetHistory();
- },
- );
- private initializeScene = async () => {
- if ("launchQueue" in window && "LaunchParams" in window) {
- (window as any).launchQueue.setConsumer(
- async (launchParams: { files: any[] }) => {
- if (!launchParams.files.length) {
- return;
- }
- const fileHandle = launchParams.files[0];
- const blob: Blob = await fileHandle.getFile();
- this.loadFileToCanvas(
- new File([blob], blob.name || "", { type: blob.type }),
- fileHandle,
- );
- },
- );
- }
- if (this.props.theme) {
- this.setState({ theme: this.props.theme });
- }
- if (!this.state.isLoading) {
- this.setState({ isLoading: true });
- }
- let initialData = null;
- try {
- if (typeof this.props.initialData === "function") {
- initialData = (await this.props.initialData()) || null;
- } else {
- initialData = (await this.props.initialData) || null;
- }
- if (initialData?.libraryItems) {
- this.library
- .updateLibrary({
- libraryItems: initialData.libraryItems,
- merge: true,
- })
- .catch((error) => {
- console.error(error);
- });
- }
- } catch (error: any) {
- console.error(error);
- initialData = {
- appState: {
- errorMessage:
- error.message ||
- "Encountered an error during importing or restoring scene data",
- },
- };
- }
- const scene = restore(initialData, null, null, { repairBindings: true });
- scene.appState = {
- ...scene.appState,
- theme: this.props.theme || scene.appState.theme,
- // 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)
- openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
- activeTool:
- scene.appState.activeTool.type === "image"
- ? { ...scene.appState.activeTool, type: "selection" }
- : scene.appState.activeTool,
- isLoading: false,
- toast: this.state.toast,
- };
- if (initialData?.scrollToContent) {
- scene.appState = {
- ...scene.appState,
- ...calculateScrollCenter(scene.elements, {
- ...scene.appState,
- width: this.state.width,
- height: this.state.height,
- offsetTop: this.state.offsetTop,
- offsetLeft: this.state.offsetLeft,
- }),
- };
- }
- this.resetStore();
- this.resetHistory();
- this.syncActionResult({
- ...scene,
- captureUpdate: CaptureUpdateAction.NEVER,
- });
- // clear the shape and image cache so that any images in initialData
- // can be loaded fresh
- this.clearImageShapeCache();
- // manually loading the font faces seems faster even in browsers that do fire the loadingdone event
- this.fonts.loadSceneFonts().then((fontFaces) => {
- this.fonts.onLoaded(fontFaces);
- });
- if (isElementLink(window.location.href)) {
- this.scrollToContent(window.location.href, { animate: false });
- }
- };
- private isMobileBreakpoint = (width: number, height: number) => {
- return (
- width < MQ_MAX_WIDTH_PORTRAIT ||
- (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
- );
- };
- private refreshViewportBreakpoints = () => {
- const container = this.excalidrawContainerRef.current;
- if (!container) {
- return;
- }
- const { clientWidth: viewportWidth, clientHeight: viewportHeight } =
- document.body;
- const prevViewportState = this.device.viewport;
- const nextViewportState = updateObject(prevViewportState, {
- isLandscape: viewportWidth > viewportHeight,
- isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight),
- });
- if (prevViewportState !== nextViewportState) {
- this.device = { ...this.device, viewport: nextViewportState };
- return true;
- }
- return false;
- };
- private refreshEditorBreakpoints = () => {
- const container = this.excalidrawContainerRef.current;
- if (!container) {
- return;
- }
- const { width: editorWidth, height: editorHeight } =
- container.getBoundingClientRect();
- const sidebarBreakpoint =
- this.props.UIOptions.dockedSidebarBreakpoint != null
- ? this.props.UIOptions.dockedSidebarBreakpoint
- : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
- const prevEditorState = this.device.editor;
- const nextEditorState = updateObject(prevEditorState, {
- isMobile: this.isMobileBreakpoint(editorWidth, editorHeight),
- canFitSidebar: editorWidth > sidebarBreakpoint,
- });
- if (prevEditorState !== nextEditorState) {
- this.device = { ...this.device, editor: nextEditorState };
- return true;
- }
- return false;
- };
- private clearImageShapeCache(filesMap?: BinaryFiles) {
- const files = filesMap ?? this.files;
- this.scene.getNonDeletedElements().forEach((element) => {
- if (isInitializedImageElement(element) && files[element.fileId]) {
- this.imageCache.delete(element.fileId);
- ShapeCache.delete(element);
- }
- });
- }
- public async componentDidMount() {
- this.unmounted = false;
- this.excalidrawContainerValue.container =
- this.excalidrawContainerRef.current;
- if (isTestEnv() || isDevEnv()) {
- const setState = this.setState.bind(this);
- Object.defineProperties(window.h, {
- state: {
- configurable: true,
- get: () => {
- return this.state;
- },
- },
- setState: {
- configurable: true,
- value: (...args: Parameters<typeof setState>) => {
- return this.setState(...args);
- },
- },
- app: {
- configurable: true,
- value: this,
- },
- history: {
- configurable: true,
- value: this.history,
- },
- store: {
- configurable: true,
- value: this.store,
- },
- fonts: {
- configurable: true,
- value: this.fonts,
- },
- });
- }
- this.store.onDurableIncrementEmitter.on((increment) => {
- this.history.record(increment.delta);
- });
- const { onIncrement } = this.props;
- // per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
- if (onIncrement) {
- this.store.onStoreIncrementEmitter.on((increment) => {
- onIncrement(increment);
- });
- }
- this.scene.onUpdate(this.triggerRender);
- this.addEventListeners();
- if (this.props.autoFocus && this.excalidrawContainerRef.current) {
- this.focusContainer();
- }
- if (
- // 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
- !isTestEnv()
- ) {
- this.refreshViewportBreakpoints();
- this.refreshEditorBreakpoints();
- }
- if (supportsResizeObserver && this.excalidrawContainerRef.current) {
- this.resizeObserver = new ResizeObserver(() => {
- this.refreshEditorBreakpoints();
- this.updateDOMRect();
- });
- this.resizeObserver?.observe(this.excalidrawContainerRef.current);
- }
- const searchParams = new URLSearchParams(window.location.search.slice(1));
- if (searchParams.has("web-share-target")) {
- // Obtain a file that was shared via the Web Share Target API.
- this.restoreFileFromShare();
- } else {
- this.updateDOMRect(this.initializeScene);
- }
- // note that this check seems to always pass in localhost
- if (isBrave() && !isMeasureTextSupported()) {
- this.setState({
- errorMessage: <BraveMeasureTextError />,
- });
- }
- }
- public componentWillUnmount() {
- (window as any).launchQueue?.setConsumer(() => {});
- this.renderer.destroy();
- this.scene.destroy();
- this.scene = new Scene();
- this.fonts = new Fonts(this.scene);
- this.renderer = new Renderer(this.scene);
- this.files = {};
- this.imageCache.clear();
- this.resizeObserver?.disconnect();
- this.unmounted = true;
- this.removeEventListeners();
- this.library.destroy();
- this.laserTrails.stop();
- this.eraserTrail.stop();
- this.onChangeEmitter.clear();
- this.store.onStoreIncrementEmitter.clear();
- this.store.onDurableIncrementEmitter.clear();
- ShapeCache.destroy();
- SnapCache.destroy();
- clearTimeout(touchTimeout);
- isSomeElementSelected.clearCache();
- selectGroupsForSelectedElements.clearCache();
- touchTimeout = 0;
- document.documentElement.style.overscrollBehaviorX = "";
- }
- private onResize = withBatchedUpdates(() => {
- this.scene
- .getElementsIncludingDeleted()
- .forEach((element) => ShapeCache.delete(element));
- this.refreshViewportBreakpoints();
- this.updateDOMRect();
- if (!supportsResizeObserver) {
- this.refreshEditorBreakpoints();
- }
- this.setState({});
- });
- /** generally invoked only if fullscreen was invoked programmatically */
- private onFullscreenChange = () => {
- if (
- // points to the iframe element we fullscreened
- !document.fullscreenElement &&
- this.state.activeEmbeddable?.state === "active"
- ) {
- this.setState({
- activeEmbeddable: null,
- });
- }
- };
- private removeEventListeners() {
- this.onRemoveEventListenersEmitter.trigger();
- }
- private addEventListeners() {
- // remove first as we can add event listeners multiple times
- this.removeEventListeners();
- // -------------------------------------------------------------------------
- // view+edit mode listeners
- // -------------------------------------------------------------------------
- if (this.props.handleKeyboardGlobally) {
- this.onRemoveEventListenersEmitter.once(
- addEventListener(document, EVENT.KEYDOWN, this.onKeyDown, false),
- );
- }
- this.onRemoveEventListenersEmitter.once(
- addEventListener(
- this.excalidrawContainerRef.current,
- EVENT.WHEEL,
- this.handleWheel,
- { passive: false },
- ),
- addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
- addEventListener(document, EVENT.POINTER_UP, this.removePointer, {
- passive: false,
- }), // #3553
- addEventListener(document, EVENT.COPY, this.onCopy, { passive: false }),
- addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }),
- addEventListener(
- document,
- EVENT.POINTER_MOVE,
- this.updateCurrentCursorPosition,
- { passive: false },
- ),
- // rerender text elements on font load to fix #637 && #1553
- addEventListener(
- document.fonts,
- "loadingdone",
- (event) => {
- const fontFaces = (event as FontFaceSetLoadEvent).fontfaces;
- this.fonts.onLoaded(fontFaces);
- },
- { passive: false },
- ),
- // Safari-only desktop pinch zoom
- addEventListener(
- document,
- EVENT.GESTURE_START,
- this.onGestureStart as any,
- false,
- ),
- addEventListener(
- document,
- EVENT.GESTURE_CHANGE,
- this.onGestureChange as any,
- false,
- ),
- addEventListener(
- document,
- EVENT.GESTURE_END,
- this.onGestureEnd as any,
- false,
- ),
- addEventListener(
- window,
- EVENT.FOCUS,
- () => {
- this.maybeCleanupAfterMissingPointerUp(null);
- // browsers (chrome?) tend to free up memory a lot, which results
- // in canvas context being cleared. Thus re-render on focus.
- this.triggerRender(true);
- },
- { passive: false },
- ),
- );
- if (this.state.viewModeEnabled) {
- return;
- }
- // -------------------------------------------------------------------------
- // edit-mode listeners only
- // -------------------------------------------------------------------------
- this.onRemoveEventListenersEmitter.once(
- addEventListener(
- document,
- EVENT.FULLSCREENCHANGE,
- this.onFullscreenChange,
- { passive: false },
- ),
- addEventListener(document, EVENT.PASTE, this.pasteFromClipboard, {
- passive: false,
- }),
- addEventListener(document, EVENT.CUT, this.onCut, { passive: false }),
- addEventListener(window, EVENT.RESIZE, this.onResize, false),
- addEventListener(window, EVENT.UNLOAD, this.onUnload, false),
- addEventListener(window, EVENT.BLUR, this.onBlur, false),
- addEventListener(
- this.excalidrawContainerRef.current,
- EVENT.WHEEL,
- this.handleWheel,
- { passive: false },
- ),
- addEventListener(
- this.excalidrawContainerRef.current,
- EVENT.DRAG_OVER,
- this.disableEvent,
- false,
- ),
- addEventListener(
- this.excalidrawContainerRef.current,
- EVENT.DROP,
- this.disableEvent,
- false,
- ),
- );
- if (this.props.detectScroll) {
- this.onRemoveEventListenersEmitter.once(
- addEventListener(
- getNearestScrollableContainer(this.excalidrawContainerRef.current!),
- EVENT.SCROLL,
- this.onScroll,
- { passive: false },
- ),
- );
- }
- }
- componentDidUpdate(prevProps: AppProps, prevState: AppState) {
- this.updateEmbeddables();
- const elements = this.scene.getElementsIncludingDeleted();
- const elementsMap = this.scene.getElementsMapIncludingDeleted();
- if (!this.state.showWelcomeScreen && !elements.length) {
- this.setState({ showWelcomeScreen: true });
- }
- if (
- prevProps.UIOptions.dockedSidebarBreakpoint !==
- this.props.UIOptions.dockedSidebarBreakpoint
- ) {
- this.refreshEditorBreakpoints();
- }
- const hasFollowedPersonLeft =
- prevState.userToFollow &&
- !this.state.collaborators.has(prevState.userToFollow.socketId);
- if (hasFollowedPersonLeft) {
- this.maybeUnfollowRemoteUser();
- }
- if (
- prevState.zoom.value !== this.state.zoom.value ||
- prevState.scrollX !== this.state.scrollX ||
- prevState.scrollY !== this.state.scrollY
- ) {
- this.props?.onScrollChange?.(
- this.state.scrollX,
- this.state.scrollY,
- this.state.zoom,
- );
- this.onScrollChangeEmitter.trigger(
- this.state.scrollX,
- this.state.scrollY,
- this.state.zoom,
- );
- }
- if (prevState.userToFollow !== this.state.userToFollow) {
- if (prevState.userToFollow) {
- this.onUserFollowEmitter.trigger({
- userToFollow: prevState.userToFollow,
- action: "UNFOLLOW",
- });
- }
- if (this.state.userToFollow) {
- this.onUserFollowEmitter.trigger({
- userToFollow: this.state.userToFollow,
- action: "FOLLOW",
- });
- }
- }
- if (
- Object.keys(this.state.selectedElementIds).length &&
- isEraserActive(this.state)
- ) {
- this.setState({
- activeTool: updateActiveTool(this.state, { type: "selection" }),
- });
- }
- if (
- this.state.activeTool.type === "eraser" &&
- prevState.theme !== this.state.theme
- ) {
- setEraserCursor(this.interactiveCanvas, this.state.theme);
- }
- // Hide hyperlink popup if shown when element type is not selection
- if (
- prevState.activeTool.type === "selection" &&
- this.state.activeTool.type !== "selection" &&
- this.state.showHyperlinkPopup
- ) {
- this.setState({ showHyperlinkPopup: false });
- }
- if (prevProps.langCode !== this.props.langCode) {
- this.updateLanguage();
- }
- if (isEraserActive(prevState) && !isEraserActive(this.state)) {
- this.eraserTrail.endPath();
- }
- if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
- this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
- }
- if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
- this.addEventListeners();
- this.deselectElements();
- }
- // cleanup
- if (
- (prevState.openDialog?.name === "elementLinkSelector" ||
- this.state.openDialog?.name === "elementLinkSelector") &&
- prevState.openDialog?.name !== this.state.openDialog?.name
- ) {
- this.deselectElements();
- this.setState({
- hoveredElementIds: {},
- });
- }
- if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
- this.setState({ zenModeEnabled: !!this.props.zenModeEnabled });
- }
- if (prevProps.theme !== this.props.theme && this.props.theme) {
- this.setState({ theme: this.props.theme });
- }
- this.excalidrawContainerRef.current?.classList.toggle(
- "theme--dark",
- this.state.theme === THEME.DARK,
- );
- if (
- this.state.selectedLinearElement?.isEditing &&
- !this.state.selectedElementIds[this.state.selectedLinearElement.elementId]
- ) {
- // defer so that the scheduleCapture flag isn't reset via current update
- setTimeout(() => {
- // execute only if the condition still holds when the deferred callback
- // executes (it can be scheduled multiple times depending on how
- // many times the component renders)
- this.state.selectedLinearElement?.isEditing &&
- this.actionManager.executeAction(actionFinalize);
- });
- }
- // failsafe in case the state is being updated in incorrect order resulting
- // in the editingTextElement being now a deleted element
- if (this.state.editingTextElement?.isDeleted) {
- this.setState({ editingTextElement: null });
- }
- if (
- this.state.selectedLinearElement &&
- !this.state.selectedElementIds[this.state.selectedLinearElement.elementId]
- ) {
- // To make sure `selectedLinearElement` is in sync with `selectedElementIds`, however this shouldn't be needed once
- // we have a single API to update `selectedElementIds`
- this.setState({ selectedLinearElement: null });
- }
- this.store.commit(elementsMap, this.state);
- // Do not notify consumers if we're still loading the scene. Among other
- // potential issues, this fixes a case where the tab isn't focused during
- // init, which would trigger onChange with empty elements, which would then
- // override whatever is in localStorage currently.
- if (!this.state.isLoading) {
- this.props.onChange?.(elements, this.state, this.files);
- this.onChangeEmitter.trigger(elements, this.state, this.files);
- }
- }
- private renderInteractiveSceneCallback = ({
- atLeastOneVisibleElement,
- scrollBars,
- elementsMap,
- }: RenderInteractiveSceneCallback) => {
- if (scrollBars) {
- currentScrollBars = scrollBars;
- }
- const scrolledOutside =
- // hide when editing text
- this.state.editingTextElement
- ? false
- : !atLeastOneVisibleElement && elementsMap.size > 0;
- if (this.state.scrolledOutside !== scrolledOutside) {
- this.setState({ scrolledOutside });
- }
- this.scheduleImageRefresh();
- };
- private onScroll = debounce(() => {
- const { offsetTop, offsetLeft } = this.getCanvasOffsets();
- this.setState((state) => {
- if (state.offsetLeft === offsetLeft && state.offsetTop === offsetTop) {
- return null;
- }
- return { offsetTop, offsetLeft };
- });
- }, SCROLL_TIMEOUT);
- // Copy/paste
- private onCut = withBatchedUpdates((event: ClipboardEvent) => {
- const isExcalidrawActive = this.excalidrawContainerRef.current?.contains(
- document.activeElement,
- );
- if (!isExcalidrawActive || isWritableElement(event.target)) {
- return;
- }
- this.actionManager.executeAction(actionCut, "keyboard", event);
- event.preventDefault();
- event.stopPropagation();
- });
- private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
- const isExcalidrawActive = this.excalidrawContainerRef.current?.contains(
- document.activeElement,
- );
- if (!isExcalidrawActive || isWritableElement(event.target)) {
- return;
- }
- this.actionManager.executeAction(actionCopy, "keyboard", event);
- event.preventDefault();
- event.stopPropagation();
- });
- private static resetTapTwice() {
- didTapTwice = false;
- }
- private onTouchStart = (event: TouchEvent) => {
- // fix for Apple Pencil Scribble (do not prevent for other devices)
- if (isIOS) {
- event.preventDefault();
- }
- if (!didTapTwice) {
- didTapTwice = true;
- clearTimeout(tappedTwiceTimer);
- tappedTwiceTimer = window.setTimeout(
- App.resetTapTwice,
- TAP_TWICE_TIMEOUT,
- );
- return;
- }
- // insert text only if we tapped twice with a single finger
- // event.touches.length === 1 will also prevent inserting text when user's zooming
- if (didTapTwice && event.touches.length === 1) {
- const touch = event.touches[0];
- // @ts-ignore
- this.handleCanvasDoubleClick({
- clientX: touch.clientX,
- clientY: touch.clientY,
- });
- didTapTwice = false;
- clearTimeout(tappedTwiceTimer);
- }
- if (event.touches.length === 2) {
- this.setState({
- selectedElementIds: makeNextSelectedElementIds({}, this.state),
- activeEmbeddable: null,
- });
- }
- };
- private onTouchEnd = (event: TouchEvent) => {
- this.resetContextMenuTimer();
- if (event.touches.length > 0) {
- this.setState({
- previousSelectedElementIds: {},
- selectedElementIds: makeNextSelectedElementIds(
- this.state.previousSelectedElementIds,
- this.state,
- ),
- });
- } else {
- gesture.pointers.clear();
- }
- };
- // TODO: this is so spaghetti, we should refactor it and cover it with tests
- public pasteFromClipboard = withBatchedUpdates(
- async (event: ClipboardEvent) => {
- const isPlainPaste = !!IS_PLAIN_PASTE;
- // #686
- const target = document.activeElement;
- const isExcalidrawActive =
- this.excalidrawContainerRef.current?.contains(target);
- if (event && !isExcalidrawActive) {
- return;
- }
- const elementUnderCursor = document.elementFromPoint(
- this.lastViewportPosition.x,
- this.lastViewportPosition.y,
- );
- if (
- event &&
- (!(elementUnderCursor instanceof HTMLCanvasElement) ||
- isWritableElement(target))
- ) {
- return;
- }
- const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
- {
- clientX: this.lastViewportPosition.x,
- clientY: this.lastViewportPosition.y,
- },
- this.state,
- );
- // must be called in the same frame (thus before any awaits) as the paste
- // event else some browsers (FF...) will clear the clipboardData
- // (something something security)
- let file = event?.clipboardData?.files[0];
- const data = await parseClipboard(event, isPlainPaste);
- if (!file && !isPlainPaste) {
- if (data.mixedContent) {
- return this.addElementsFromMixedContentPaste(data.mixedContent, {
- isPlainPaste,
- sceneX,
- sceneY,
- });
- } else if (data.text) {
- const string = data.text.trim();
- if (string.startsWith("<svg") && string.endsWith("</svg>")) {
- // ignore SVG validation/normalization which will be done during image
- // initialization
- file = SVGStringToFile(string);
- }
- }
- }
- // prefer spreadsheet data over image file (MS Office/Libre Office)
- if (isSupportedImageFile(file) && !data.spreadsheet) {
- if (!this.isToolSupported("image")) {
- this.setState({ errorMessage: t("errors.imageToolNotSupported") });
- return;
- }
- this.createImageElement({ sceneX, sceneY, imageFile: file });
- return;
- }
- if (this.props.onPaste) {
- try {
- if ((await this.props.onPaste(data, event)) === false) {
- return;
- }
- } catch (error: any) {
- console.error(error);
- }
- }
- if (data.errorMessage) {
- this.setState({ errorMessage: data.errorMessage });
- } else if (data.spreadsheet && !isPlainPaste) {
- this.setState({
- pasteDialog: {
- data: data.spreadsheet,
- shown: true,
- },
- });
- } else if (data.elements) {
- const elements = (
- data.programmaticAPI
- ? convertToExcalidrawElements(
- data.elements as ExcalidrawElementSkeleton[],
- )
- : data.elements
- ) as readonly ExcalidrawElement[];
- // TODO remove formatting from elements if isPlainPaste
- this.addElementsFromPasteOrLibrary({
- elements,
- files: data.files || null,
- position: "cursor",
- retainSeed: isPlainPaste,
- });
- } else if (data.text) {
- if (data.text && isMaybeMermaidDefinition(data.text)) {
- const api = await import("@excalidraw/mermaid-to-excalidraw");
- try {
- const { elements: skeletonElements, files } =
- await api.parseMermaidToExcalidraw(data.text);
- const elements = convertToExcalidrawElements(skeletonElements, {
- regenerateIds: true,
- });
- this.addElementsFromPasteOrLibrary({
- elements,
- files,
- position: "cursor",
- });
- return;
- } catch (err: any) {
- console.warn(
- `parsing pasted text as mermaid definition failed: ${err.message}`,
- );
- }
- }
- const nonEmptyLines = normalizeEOL(data.text)
- .split(/\n+/)
- .map((s) => s.trim())
- .filter(Boolean);
- const embbeddableUrls = nonEmptyLines
- .map((str) => maybeParseEmbedSrc(str))
- .filter((string) => {
- return (
- embeddableURLValidator(string, this.props.validateEmbeddable) &&
- (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
- getEmbedLink(string)?.type === "video")
- );
- });
- if (
- !IS_PLAIN_PASTE &&
- embbeddableUrls.length > 0 &&
- // if there were non-embeddable text (lines) mixed in with embeddable
- // urls, ignore and paste as text
- embbeddableUrls.length === nonEmptyLines.length
- ) {
- const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
- for (const url of embbeddableUrls) {
- const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
- embeddables[embeddables.length - 1];
- const embeddable = this.insertEmbeddableElement({
- sceneX: prevEmbeddable
- ? prevEmbeddable.x + prevEmbeddable.width + 20
- : sceneX,
- sceneY,
- link: normalizeLink(url),
- });
- if (embeddable) {
- embeddables.push(embeddable);
- }
- }
- if (embeddables.length) {
- this.store.scheduleCapture();
- this.setState({
- selectedElementIds: Object.fromEntries(
- embeddables.map((embeddable) => [embeddable.id, true]),
- ),
- });
- }
- return;
- }
- this.addTextFromPaste(data.text, isPlainPaste);
- }
- this.setActiveTool({ type: "selection" });
- event?.preventDefault();
- },
- );
- addElementsFromPasteOrLibrary = (opts: {
- elements: readonly ExcalidrawElement[];
- files: BinaryFiles | null;
- position: { clientX: number; clientY: number } | "cursor" | "center";
- retainSeed?: boolean;
- fitToContent?: boolean;
- }) => {
- const elements = restoreElements(opts.elements, null, undefined);
- const [minX, minY, maxX, maxY] = getCommonBounds(elements);
- const elementsCenterX = distance(minX, maxX) / 2;
- const elementsCenterY = distance(minY, maxY) / 2;
- const clientX =
- typeof opts.position === "object"
- ? opts.position.clientX
- : opts.position === "cursor"
- ? this.lastViewportPosition.x
- : this.state.width / 2 + this.state.offsetLeft;
- const clientY =
- typeof opts.position === "object"
- ? opts.position.clientY
- : opts.position === "cursor"
- ? this.lastViewportPosition.y
- : this.state.height / 2 + this.state.offsetTop;
- const { x, y } = viewportCoordsToSceneCoords(
- { clientX, clientY },
- this.state,
- );
- const dx = x - elementsCenterX;
- const dy = y - elementsCenterY;
- const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
- const { duplicatedElements } = duplicateElements({
- type: "everything",
- elements: elements.map((element) => {
- return newElementWith(element, {
- x: element.x + gridX - minX,
- y: element.y + gridY - minY,
- });
- }),
- randomizeSeed: !opts.retainSeed,
- });
- const prevElements = this.scene.getElementsIncludingDeleted();
- let nextElements = [...prevElements, ...duplicatedElements];
- const mappedNewSceneElements = this.props.onDuplicate?.(
- nextElements,
- prevElements,
- );
- nextElements = mappedNewSceneElements || nextElements;
- syncMovedIndices(nextElements, arrayToMap(duplicatedElements));
- const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y });
- if (topLayerFrame) {
- const eligibleElements = filterElementsEligibleAsFrameChildren(
- duplicatedElements,
- topLayerFrame,
- );
- addElementsToFrame(
- nextElements,
- eligibleElements,
- topLayerFrame,
- this.state,
- );
- }
- this.scene.replaceAllElements(nextElements);
- duplicatedElements.forEach((newElement) => {
- if (isTextElement(newElement) && isBoundToContainer(newElement)) {
- const container = getContainerElement(
- newElement,
- this.scene.getElementsMapIncludingDeleted(),
- );
- redrawTextBoundingBox(newElement, container, this.scene);
- }
- });
- // paste event may not fire FontFace loadingdone event in Safari, hence loading font faces manually
- if (isSafari) {
- Fonts.loadElementsFonts(duplicatedElements).then((fontFaces) => {
- this.fonts.onLoaded(fontFaces);
- });
- }
- if (opts.files) {
- this.addMissingFiles(opts.files);
- }
- const nextElementsToSelect =
- excludeElementsInFramesFromSelection(duplicatedElements);
- this.store.scheduleCapture();
- this.setState(
- {
- ...this.state,
- // keep sidebar (presumably the library) open if it's docked and
- // can fit.
- //
- // Note, we should close the sidebar only if we're dropping items
- // from library, not when pasting from clipboard. Alas.
- openSidebar:
- this.state.openSidebar &&
- this.device.editor.canFitSidebar &&
- editorJotaiStore.get(isSidebarDockedAtom)
- ? this.state.openSidebar
- : null,
- ...selectGroupsForSelectedElements(
- {
- editingGroupId: null,
- selectedElementIds: nextElementsToSelect.reduce(
- (acc: Record<ExcalidrawElement["id"], true>, element) => {
- if (!isBoundToContainer(element)) {
- acc[element.id] = true;
- }
- return acc;
- },
- {},
- ),
- },
- this.scene.getNonDeletedElements(),
- this.state,
- this,
- ),
- },
- () => {
- if (opts.files) {
- this.addNewImagesToImageCache();
- }
- },
- );
- this.setActiveTool({ type: "selection" });
- if (opts.fitToContent) {
- this.scrollToContent(duplicatedElements, {
- fitToContent: true,
- canvasOffsets: this.getEditorUIOffsets(),
- });
- }
- };
- // TODO rewrite this to paste both text & images at the same time if
- // pasted data contains both
- private async addElementsFromMixedContentPaste(
- mixedContent: PastedMixedContent,
- {
- isPlainPaste,
- sceneX,
- sceneY,
- }: { isPlainPaste: boolean; sceneX: number; sceneY: number },
- ) {
- if (
- !isPlainPaste &&
- mixedContent.some((node) => node.type === "imageUrl") &&
- this.isToolSupported("image")
- ) {
- const imageURLs = mixedContent
- .filter((node) => node.type === "imageUrl")
- .map((node) => node.value);
- const responses = await Promise.all(
- imageURLs.map(async (url) => {
- try {
- return { file: await ImageURLToFile(url) };
- } catch (error: any) {
- let errorMessage = error.message;
- if (error.cause === "FETCH_ERROR") {
- errorMessage = t("errors.failedToFetchImage");
- } else if (error.cause === "UNSUPPORTED") {
- errorMessage = t("errors.unsupportedFileType");
- }
- return { errorMessage };
- }
- }),
- );
- let y = sceneY;
- let firstImageYOffsetDone = false;
- const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
- for (const response of responses) {
- if (response.file) {
- const initializedImageElement = await this.createImageElement({
- sceneX,
- sceneY: y,
- imageFile: response.file,
- });
- if (initializedImageElement) {
- // vertically center first image in the batch
- if (!firstImageYOffsetDone) {
- firstImageYOffsetDone = true;
- y -= initializedImageElement.height / 2;
- }
- // hack to reset the `y` coord because we vertically center during
- // insertImageElement
- this.scene.mutateElement(
- initializedImageElement,
- { y },
- { informMutation: false, isDragging: false },
- );
- y = initializedImageElement.y + initializedImageElement.height + 25;
- nextSelectedIds[initializedImageElement.id] = true;
- }
- }
- }
- this.setState({
- selectedElementIds: makeNextSelectedElementIds(
- nextSelectedIds,
- this.state,
- ),
- });
- const error = responses.find((response) => !!response.errorMessage);
- if (error && error.errorMessage) {
- this.setState({ errorMessage: error.errorMessage });
- }
- } else {
- const textNodes = mixedContent.filter((node) => node.type === "text");
- if (textNodes.length) {
- this.addTextFromPaste(
- textNodes.map((node) => node.value).join("\n\n"),
- isPlainPaste,
- );
- }
- }
- }
- private addTextFromPaste(text: string, isPlainPaste = false) {
- const { x, y } = viewportCoordsToSceneCoords(
- {
- clientX: this.lastViewportPosition.x,
- clientY: this.lastViewportPosition.y,
- },
- this.state,
- );
- const textElementProps = {
- x,
- y,
- strokeColor: this.state.currentItemStrokeColor,
- backgroundColor: this.state.currentItemBackgroundColor,
- fillStyle: this.state.currentItemFillStyle,
- strokeWidth: this.state.currentItemStrokeWidth,
- strokeStyle: this.state.currentItemStrokeStyle,
- roundness: null,
- roughness: this.state.currentItemRoughness,
- opacity: this.state.currentItemOpacity,
- text,
- fontSize: this.state.currentItemFontSize,
- fontFamily: this.state.currentItemFontFamily,
- textAlign: DEFAULT_TEXT_ALIGN,
- verticalAlign: DEFAULT_VERTICAL_ALIGN,
- locked: false,
- };
- const fontString = getFontString({
- fontSize: textElementProps.fontSize,
- fontFamily: textElementProps.fontFamily,
- });
- const lineHeight = getLineHeight(textElementProps.fontFamily);
- const [x1, , x2] = getVisibleSceneBounds(this.state);
- // long texts should not go beyond 800 pixels in width nor should it go below 200 px
- const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
- const LINE_GAP = 10;
- let currentY = y;
- const lines = isPlainPaste ? [text] : text.split("\n");
- const textElements = lines.reduce(
- (acc: ExcalidrawTextElement[], line, idx) => {
- const originalText = normalizeText(line).trim();
- if (originalText.length) {
- const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
- x,
- y: currentY,
- });
- let metrics = measureText(originalText, fontString, lineHeight);
- const isTextUnwrapped = metrics.width > maxTextWidth;
- const text = isTextUnwrapped
- ? wrapText(originalText, fontString, maxTextWidth)
- : originalText;
- metrics = isTextUnwrapped
- ? measureText(text, fontString, lineHeight)
- : metrics;
- const startX = x - metrics.width / 2;
- const startY = currentY - metrics.height / 2;
- const element = newTextElement({
- ...textElementProps,
- x: startX,
- y: startY,
- text,
- originalText,
- lineHeight,
- autoResize: !isTextUnwrapped,
- frameId: topLayerFrame ? topLayerFrame.id : null,
- });
- acc.push(element);
- currentY += element.height + LINE_GAP;
- } else {
- const prevLine = lines[idx - 1]?.trim();
- // add paragraph only if previous line was not empty, IOW don't add
- // more than one empty line
- if (prevLine) {
- currentY +=
- getLineHeightInPx(textElementProps.fontSize, lineHeight) +
- LINE_GAP;
- }
- }
- return acc;
- },
- [],
- );
- if (textElements.length === 0) {
- return;
- }
- this.scene.insertElements(textElements);
- this.store.scheduleCapture();
- this.setState({
- selectedElementIds: makeNextSelectedElementIds(
- Object.fromEntries(textElements.map((el) => [el.id, true])),
- this.state,
- ),
- });
- if (
- !isPlainPaste &&
- textElements.length > 1 &&
- PLAIN_PASTE_TOAST_SHOWN === false &&
- !this.device.editor.isMobile
- ) {
- this.setToast({
- message: t("toast.pasteAsSingleElement", {
- shortcut: getShortcutKey("CtrlOrCmd+Shift+V"),
- }),
- duration: 5000,
- });
- PLAIN_PASTE_TOAST_SHOWN = true;
- }
- }
- setAppState: React.Component<any, AppState>["setState"] = (
- state,
- callback,
- ) => {
- this.setState(state, callback);
- };
- removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
- if (touchTimeout) {
- this.resetContextMenuTimer();
- }
- gesture.pointers.delete(event.pointerId);
- };
- toggleLock = (source: "keyboard" | "ui" = "ui") => {
- if (!this.state.activeTool.locked) {
- trackEvent(
- "toolbar",
- "toggleLock",
- `${source} (${this.device.editor.isMobile ? "mobile" : "desktop"})`,
- );
- }
- this.setState((prevState) => {
- return {
- activeTool: {
- ...prevState.activeTool,
- ...updateActiveTool(
- this.state,
- prevState.activeTool.locked
- ? { type: "selection" }
- : prevState.activeTool,
- ),
- locked: !prevState.activeTool.locked,
- },
- };
- });
- };
- updateFrameRendering = (
- opts:
- | Partial<AppState["frameRendering"]>
- | ((
- prevState: AppState["frameRendering"],
- ) => Partial<AppState["frameRendering"]>),
- ) => {
- this.setState((prevState) => {
- const next =
- typeof opts === "function" ? opts(prevState.frameRendering) : opts;
- return {
- frameRendering: {
- enabled: next?.enabled ?? prevState.frameRendering.enabled,
- clip: next?.clip ?? prevState.frameRendering.clip,
- name: next?.name ?? prevState.frameRendering.name,
- outline: next?.outline ?? prevState.frameRendering.outline,
- },
- };
- });
- };
- togglePenMode = (force: boolean | null) => {
- this.setState((prevState) => {
- return {
- penMode: force ?? !prevState.penMode,
- penDetected: true,
- };
- });
- };
- onHandToolToggle = () => {
- this.actionManager.executeAction(actionToggleHandTool);
- };
- /**
- * Zooms on canvas viewport center
- */
- zoomCanvas = (
- /**
- * Decimal fraction, auto-clamped between MIN_ZOOM and MAX_ZOOM.
- * 1 = 100% zoom, 2 = 200% zoom, 0.5 = 50% zoom
- */
- value: number,
- ) => {
- this.setState({
- ...getStateForZoom(
- {
- viewportX: this.state.width / 2 + this.state.offsetLeft,
- viewportY: this.state.height / 2 + this.state.offsetTop,
- nextZoom: getNormalizedZoom(value),
- },
- this.state,
- ),
- });
- };
- private cancelInProgressAnimation: (() => void) | null = null;
- scrollToContent = (
- /**
- * target to scroll to
- *
- * - string - id of element or group, or url containing elementLink
- * - ExcalidrawElement | ExcalidrawElement[] - element(s) objects
- */
- target:
- | string
- | ExcalidrawElement
- | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
- opts?: (
- | {
- fitToContent?: boolean;
- fitToViewport?: never;
- viewportZoomFactor?: number;
- animate?: boolean;
- duration?: number;
- }
- | {
- fitToContent?: never;
- fitToViewport?: boolean;
- /** when fitToViewport=true, how much screen should the content cover,
- * between 0.1 (10%) and 1 (100%)
- */
- viewportZoomFactor?: number;
- animate?: boolean;
- duration?: number;
- }
- ) & {
- minZoom?: number;
- maxZoom?: number;
- canvasOffsets?: Offsets;
- },
- ) => {
- if (typeof target === "string") {
- let id: string | null;
- if (isElementLink(target)) {
- id = parseElementLinkFromURL(target);
- } else {
- id = target;
- }
- if (id) {
- const elements = this.scene.getElementsFromId(id);
- if (elements?.length) {
- this.scrollToContent(elements, {
- fitToContent: opts?.fitToContent ?? true,
- animate: opts?.animate ?? true,
- });
- } else if (isElementLink(target)) {
- this.setState({
- toast: {
- message: t("elementLink.notFound"),
- duration: 3000,
- closable: true,
- },
- });
- }
- }
- return;
- }
- this.cancelInProgressAnimation?.();
- // convert provided target into ExcalidrawElement[] if necessary
- const targetElements = Array.isArray(target) ? target : [target];
- let zoom = this.state.zoom;
- let scrollX = this.state.scrollX;
- let scrollY = this.state.scrollY;
- if (opts?.fitToContent || opts?.fitToViewport) {
- const { appState } = zoomToFit({
- canvasOffsets: opts.canvasOffsets,
- targetElements,
- appState: this.state,
- fitToViewport: !!opts?.fitToViewport,
- viewportZoomFactor: opts?.viewportZoomFactor,
- minZoom: opts?.minZoom,
- maxZoom: opts?.maxZoom,
- });
- zoom = appState.zoom;
- scrollX = appState.scrollX;
- scrollY = appState.scrollY;
- } else {
- // compute only the viewport location, without any zoom adjustment
- const scroll = calculateScrollCenter(targetElements, this.state);
- scrollX = scroll.scrollX;
- scrollY = scroll.scrollY;
- }
- // when animating, we use RequestAnimationFrame to prevent the animation
- // from slowing down other processes
- if (opts?.animate) {
- const origScrollX = this.state.scrollX;
- const origScrollY = this.state.scrollY;
- const origZoom = this.state.zoom.value;
- const cancel = easeToValuesRAF({
- fromValues: {
- scrollX: origScrollX,
- scrollY: origScrollY,
- zoom: origZoom,
- },
- toValues: { scrollX, scrollY, zoom: zoom.value },
- interpolateValue: (from, to, progress, key) => {
- // for zoom, use different easing
- if (key === "zoom") {
- return from * Math.pow(to / from, easeOut(progress));
- }
- // handle using default
- return undefined;
- },
- onStep: ({ scrollX, scrollY, zoom }) => {
- this.setState({
- scrollX,
- scrollY,
- zoom: { value: zoom },
- });
- },
- onStart: () => {
- this.setState({ shouldCacheIgnoreZoom: true });
- },
- onEnd: () => {
- this.setState({ shouldCacheIgnoreZoom: false });
- },
- onCancel: () => {
- this.setState({ shouldCacheIgnoreZoom: false });
- },
- duration: opts?.duration ?? 500,
- });
- this.cancelInProgressAnimation = () => {
- cancel();
- this.cancelInProgressAnimation = null;
- };
- } else {
- this.setState({ scrollX, scrollY, zoom });
- }
- };
- private maybeUnfollowRemoteUser = () => {
- if (this.state.userToFollow) {
- this.setState({ userToFollow: null });
- }
- };
- /** use when changing scrollX/scrollY/zoom based on user interaction */
- private translateCanvas: React.Component<any, AppState>["setState"] = (
- state,
- ) => {
- this.cancelInProgressAnimation?.();
- this.maybeUnfollowRemoteUser();
- this.setState(state);
- };
- setToast = (
- toast: {
- message: string;
- closable?: boolean;
- duration?: number;
- } | null,
- ) => {
- this.setState({ toast });
- };
- restoreFileFromShare = async () => {
- try {
- const webShareTargetCache = await caches.open("web-share-target");
- const response = await webShareTargetCache.match("shared-file");
- if (response) {
- const blob = await response.blob();
- const file = new File([blob], blob.name || "", { type: blob.type });
- this.loadFileToCanvas(file, null);
- await webShareTargetCache.delete("shared-file");
- window.history.replaceState(null, APP_NAME, window.location.pathname);
- }
- } catch (error: any) {
- this.setState({ errorMessage: error.message });
- }
- };
- /**
- * adds supplied files to existing files in the appState.
- * NOTE if file already exists in editor state, the file data is not updated
- * */
- public addFiles: ExcalidrawImperativeAPI["addFiles"] = withBatchedUpdates(
- (files) => {
- const { addedFiles } = this.addMissingFiles(files);
- this.clearImageShapeCache(addedFiles);
- this.scene.triggerUpdate();
- this.addNewImagesToImageCache();
- },
- );
- private addMissingFiles = (
- files: BinaryFiles | BinaryFileData[],
- replace = false,
- ) => {
- const nextFiles = replace ? {} : { ...this.files };
- const addedFiles: BinaryFiles = {};
- const _files = Array.isArray(files) ? files : Object.values(files);
- for (const fileData of _files) {
- if (nextFiles[fileData.id]) {
- continue;
- }
- addedFiles[fileData.id] = fileData;
- nextFiles[fileData.id] = fileData;
- if (fileData.mimeType === MIME_TYPES.svg) {
- try {
- const restoredDataURL = getDataURL_sync(
- normalizeSVG(dataURLToString(fileData.dataURL)),
- MIME_TYPES.svg,
- );
- if (fileData.dataURL !== restoredDataURL) {
- // bump version so persistence layer can update the store
- fileData.version = (fileData.version ?? 1) + 1;
- fileData.dataURL = restoredDataURL;
- }
- } catch (error) {
- console.error(error);
- }
- }
- }
- this.files = nextFiles;
- return { addedFiles };
- };
- public updateScene = withBatchedUpdates(
- <K extends keyof AppState>(sceneData: {
- elements?: SceneData["elements"];
- appState?: Pick<AppState, K> | null;
- collaborators?: SceneData["collaborators"];
- /**
- * Controls which updates should be captured by the `Store`. Captured updates are emmitted and listened to by other components, such as `History` for undo / redo purposes.
- *
- * - `CaptureUpdateAction.IMMEDIATELY`: Updates are immediately undoable. Use for most local updates.
- * - `CaptureUpdateAction.NEVER`: Updates never make it to undo/redo stack. Use for remote updates or scene initialization.
- * - `CaptureUpdateAction.EVENTUALLY`: Updates will be eventually be captured as part of a future increment.
- *
- * Check [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#captureUpdate) for more details.
- *
- * @default CaptureUpdateAction.EVENTUALLY
- */
- captureUpdate?: SceneData["captureUpdate"];
- }) => {
- const { elements, appState, collaborators, captureUpdate } = sceneData;
- if (captureUpdate) {
- const nextElements = elements ? elements : undefined;
- const observedAppState = appState
- ? getObservedAppState({
- ...this.store.snapshot.appState,
- ...appState,
- })
- : undefined;
- this.store.scheduleMicroAction({
- action: captureUpdate,
- elements: nextElements,
- appState: observedAppState,
- });
- }
- if (appState) {
- this.setState(appState);
- }
- if (elements) {
- this.scene.replaceAllElements(elements);
- }
- if (collaborators) {
- this.setState({ collaborators });
- }
- },
- );
- public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
- element: TElement,
- updates: ElementUpdate<TElement>,
- informMutation = true,
- ) => {
- return this.scene.mutateElement(element, updates, {
- informMutation,
- isDragging: false,
- });
- };
- private triggerRender = (
- /** force always re-renders canvas even if no change */
- force?: boolean,
- ) => {
- if (force === true) {
- this.scene.triggerUpdate();
- } else {
- this.setState({});
- }
- };
- /**
- * @returns whether the menu was toggled on or off
- */
- public toggleSidebar = ({
- name,
- tab,
- force,
- }: {
- name: SidebarName | null;
- tab?: SidebarTabName;
- force?: boolean;
- }): boolean => {
- let nextName;
- if (force === undefined) {
- nextName =
- this.state.openSidebar?.name === name &&
- this.state.openSidebar?.tab === tab
- ? null
- : name;
- } else {
- nextName = force ? name : null;
- }
- const nextState: AppState["openSidebar"] = nextName
- ? { name: nextName }
- : null;
- if (nextState && tab) {
- nextState.tab = tab;
- }
- this.setState({ openSidebar: nextState });
- return !!nextName;
- };
- private updateCurrentCursorPosition = withBatchedUpdates(
- (event: MouseEvent) => {
- this.lastViewportPosition.x = event.clientX;
- this.lastViewportPosition.y = event.clientY;
- },
- );
- public getEditorUIOffsets = (): Offsets => {
- const toolbarBottom =
- this.excalidrawContainerRef?.current
- ?.querySelector(".App-toolbar")
- ?.getBoundingClientRect()?.bottom ?? 0;
- const sidebarRect = this.excalidrawContainerRef?.current
- ?.querySelector(".sidebar")
- ?.getBoundingClientRect();
- const propertiesPanelRect = this.excalidrawContainerRef?.current
- ?.querySelector(".App-menu__left")
- ?.getBoundingClientRect();
- const PADDING = 16;
- return getLanguage().rtl
- ? {
- top: toolbarBottom + PADDING,
- right:
- Math.max(
- this.state.width -
- (propertiesPanelRect?.left ?? this.state.width),
- 0,
- ) + PADDING,
- bottom: PADDING,
- left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
- }
- : {
- top: toolbarBottom + PADDING,
- right: Math.max(
- this.state.width -
- (sidebarRect?.left ?? this.state.width) +
- PADDING,
- 0,
- ),
- bottom: PADDING,
- left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
- };
- };
- // Input handling
- private onKeyDown = withBatchedUpdates(
- (event: React.KeyboardEvent | KeyboardEvent) => {
- // normalize `event.key` when CapsLock is pressed #2372
- if (
- "Proxy" in window &&
- ((!event.shiftKey && /^[A-Z]$/.test(event.key)) ||
- (event.shiftKey && /^[a-z]$/.test(event.key)))
- ) {
- event = new Proxy(event, {
- get(ev: any, prop) {
- const value = ev[prop];
- if (typeof value === "function") {
- // fix for Proxies hijacking `this`
- return value.bind(ev);
- }
- return prop === "key"
- ? // CapsLock inverts capitalization based on ShiftKey, so invert
- // it back
- event.shiftKey
- ? ev.key.toUpperCase()
- : ev.key.toLowerCase()
- : value;
- },
- });
- }
- if (!isInputLike(event.target)) {
- if (
- (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
- this.state.croppingElementId
- ) {
- this.finishImageCropping();
- return;
- }
- const selectedElements = getSelectedElements(
- this.scene.getNonDeletedElementsMap(),
- this.state,
- );
- if (
- selectedElements.length === 1 &&
- isImageElement(selectedElements[0]) &&
- event.key === KEYS.ENTER
- ) {
- this.startImageCropping(selectedElements[0]);
- return;
- }
- // Shape switching
- if (event.key === KEYS.ESCAPE) {
- this.updateEditorAtom(convertElementTypePopupAtom, null);
- } else if (
- event.key === KEYS.TAB &&
- (document.activeElement === this.excalidrawContainerRef?.current ||
- document.activeElement?.classList.contains(
- CLASSES.CONVERT_ELEMENT_TYPE_POPUP,
- ))
- ) {
- event.preventDefault();
- const conversionType =
- getConversionTypeFromElements(selectedElements);
- if (
- editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel"
- ) {
- if (
- convertElementTypes(this, {
- conversionType,
- direction: event.shiftKey ? "left" : "right",
- })
- ) {
- this.store.scheduleCapture();
- }
- }
- if (conversionType) {
- this.updateEditorAtom(convertElementTypePopupAtom, {
- type: "panel",
- });
- }
- }
- if (
- event.key === KEYS.ESCAPE &&
- this.flowChartCreator.isCreatingChart
- ) {
- this.flowChartCreator.clear();
- this.triggerRender(true);
- return;
- }
- const arrowKeyPressed = isArrowKey(event.key);
- if (event[KEYS.CTRL_OR_CMD] && arrowKeyPressed && !event.shiftKey) {
- event.preventDefault();
- const selectedElements = getSelectedElements(
- this.scene.getNonDeletedElementsMap(),
- this.state,
- );
- if (
- selectedElements.length === 1 &&
- isFlowchartNodeElement(selectedElements[0])
- ) {
- this.flowChartCreator.createNodes(
- selectedElements[0],
- this.state,
- getLinkDirectionFromKey(event.key),
- this.scene,
- );
- }
- if (
- this.flowChartCreator.pendingNodes?.length &&
- !isElementCompletelyInViewport(
- this.flowChartCreator.pendingNodes,
- this.canvas.width / window.devicePixelRatio,
- this.canvas.height / window.devicePixelRatio,
- {
- offsetLeft: this.state.offsetLeft,
- offsetTop: this.state.offsetTop,
- scrollX: this.state.scrollX,
- scrollY: this.state.scrollY,
- zoom: this.state.zoom,
- },
- this.scene.getNonDeletedElementsMap(),
- this.getEditorUIOffsets(),
- )
- ) {
- this.scrollToContent(this.flowChartCreator.pendingNodes, {
- animate: true,
- duration: 300,
- fitToContent: true,
- canvasOffsets: this.getEditorUIOffsets(),
- });
- }
- return;
- }
- if (event.altKey) {
- const selectedElements = getSelectedElements(
- this.scene.getNonDeletedElementsMap(),
- this.state,
- );
- if (selectedElements.length === 1 && arrowKeyPressed) {
- event.preventDefault();
- const nextId = this.flowChartNavigator.exploreByDirection(
- selectedElements[0],
- this.scene.getNonDeletedElementsMap(),
- getLinkDirectionFromKey(event.key),
- );
- if (nextId) {
- this.setState((prevState) => ({
- selectedElementIds: makeNextSelectedElementIds(
- {
- [nextId]: true,
- },
- prevState,
- ),
- }));
- const nextNode = this.scene
- .getNonDeletedElementsMap()
- .get(nextId);
- if (
- nextNode &&
- !isElementCompletelyInViewport(
- [nextNode],
- this.canvas.width / window.devicePixelRatio,
- this.canvas.height / window.devicePixelRatio,
- {
- offsetLeft: this.state.offsetLeft,
- offsetTop: this.state.offsetTop,
- scrollX: this.state.scrollX,
- scrollY: this.state.scrollY,
- zoom: this.state.zoom,
- },
- this.scene.getNonDeletedElementsMap(),
- this.getEditorUIOffsets(),
- )
- ) {
- this.scrollToContent(nextNode, {
- animate: true,
- duration: 300,
- canvasOffsets: this.getEditorUIOffsets(),
- });
- }
- }
- return;
- }
- }
- }
- if (
- event[KEYS.CTRL_OR_CMD] &&
- event.key === KEYS.P &&
- !event.shiftKey &&
- !event.altKey
- ) {
- this.setToast({
- message: t("commandPalette.shortcutHint", {
- shortcut: getShortcutFromShortcutName("commandPalette"),
- }),
- });
- event.preventDefault();
- return;
- }
- if (event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.V) {
- IS_PLAIN_PASTE = event.shiftKey;
- clearTimeout(IS_PLAIN_PASTE_TIMER);
- // reset (100ms to be safe that we it runs after the ensuing
- // paste event). Though, technically unnecessary to reset since we
- // (re)set the flag before each paste event.
- IS_PLAIN_PASTE_TIMER = window.setTimeout(() => {
- IS_PLAIN_PASTE = false;
- }, 100);
- }
- // prevent browser zoom in input fields
- if (event[KEYS.CTRL_OR_CMD] && isWritableElement(event.target)) {
- if (event.code === CODES.MINUS || event.code === CODES.EQUAL) {
- event.preventDefault();
- return;
- }
- }
- // bail if
- if (
- // inside an input
- (isWritableElement(event.target) &&
- // unless pressing escape (finalize action)
- event.key !== KEYS.ESCAPE) ||
- // or unless using arrows (to move between buttons)
- (isArrowKey(event.key) && isInputLike(event.target))
- ) {
- return;
- }
- if (event.key === KEYS.QUESTION_MARK) {
- this.setState({
- openDialog: { name: "help" },
- });
- return;
- } else if (
- event.key.toLowerCase() === KEYS.E &&
- event.shiftKey &&
- event[KEYS.CTRL_OR_CMD]
- ) {
- event.preventDefault();
- this.setState({ openDialog: { name: "imageExport" } });
- return;
- }
- if (event.key === KEYS.PAGE_UP || event.key === KEYS.PAGE_DOWN) {
- let offset =
- (event.shiftKey ? this.state.width : this.state.height) /
- this.state.zoom.value;
- if (event.key === KEYS.PAGE_DOWN) {
- offset = -offset;
- }
- if (event.shiftKey) {
- this.translateCanvas((state) => ({
- scrollX: state.scrollX + offset,
- }));
- } else {
- this.translateCanvas((state) => ({
- scrollY: state.scrollY + offset,
- }));
- }
- }
- if (this.state.openDialog?.name === "elementLinkSelector") {
- return;
- }
- if (this.actionManager.handleKeyDown(event)) {
- return;
- }
- if (this.state.viewModeEnabled) {
- return;
- }
- if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) {
- this.setState({ isBindingEnabled: false });
- }
- if (isArrowKey(event.key)) {
- let selectedElements = this.scene.getSelectedElements({
- selectedElementIds: this.state.selectedElementIds,
- includeBoundTextElement: true,
- includeElementsInFrames: true,
- });
- const elbowArrow = selectedElements.find(isElbowArrow) as
- | ExcalidrawArrowElement
- | undefined;
- const arrowIdsToRemove = new Set<string>();
- selectedElements
- .filter(isElbowArrow)
- .filter((arrow) => {
- const startElementNotInSelection =
- arrow.startBinding &&
- !selectedElements.some(
- (el) => el.id === arrow.startBinding?.elementId,
- );
- const endElementNotInSelection =
- arrow.endBinding &&
- !selectedElements.some(
- (el) => el.id === arrow.endBinding?.elementId,
- );
- return startElementNotInSelection || endElementNotInSelection;
- })
- .forEach((arrow) => arrowIdsToRemove.add(arrow.id));
- selectedElements = selectedElements.filter(
- (el) => !arrowIdsToRemove.has(el.id),
- );
- const step =
- (this.getEffectiveGridSize() &&
- (event.shiftKey
- ? ELEMENT_TRANSLATE_AMOUNT
- : this.getEffectiveGridSize())) ||
- (event.shiftKey
- ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
- : ELEMENT_TRANSLATE_AMOUNT);
- let offsetX = 0;
- let offsetY = 0;
- if (event.key === KEYS.ARROW_LEFT) {
- offsetX = -step;
- } else if (event.key === KEYS.ARROW_RIGHT) {
- offsetX = step;
- } else if (event.key === KEYS.ARROW_UP) {
- offsetY = -step;
- } else if (event.key === KEYS.ARROW_DOWN) {
- offsetY = step;
- }
- selectedElements.forEach((element) => {
- this.scene.mutateElement(
- element,
- {
- x: element.x + offsetX,
- y: element.y + offsetY,
- },
- { informMutation: false, isDragging: false },
- );
- updateBoundElements(element, this.scene, {
- simultaneouslyUpdated: selectedElements,
- });
- });
- this.setState({
- suggestedBindings: getSuggestedBindingsForArrows(
- selectedElements.filter(
- (element) => element.id !== elbowArrow?.id || step !== 0,
- ),
- this.scene.getNonDeletedElementsMap(),
- this.state.zoom,
- ),
- });
- this.scene.triggerUpdate();
- event.preventDefault();
- } else if (event.key === KEYS.ENTER) {
- const selectedElements = this.scene.getSelectedElements(this.state);
- if (selectedElements.length === 1) {
- const selectedElement = selectedElements[0];
- if (event[KEYS.CTRL_OR_CMD] || isLineElement(selectedElement)) {
- if (isLinearElement(selectedElement)) {
- if (
- !this.state.selectedLinearElement?.isEditing ||
- this.state.selectedLinearElement.elementId !==
- selectedElement.id
- ) {
- this.store.scheduleCapture();
- if (!isElbowArrow(selectedElement)) {
- this.actionManager.executeAction(actionToggleLinearEditor);
- }
- }
- }
- } else if (
- isTextElement(selectedElement) ||
- isValidTextContainer(selectedElement)
- ) {
- let container;
- if (!isTextElement(selectedElement)) {
- container = selectedElement as ExcalidrawTextContainer;
- }
- const midPoint = getContainerCenter(
- selectedElement,
- this.state,
- this.scene.getNonDeletedElementsMap(),
- );
- const sceneX = midPoint.x;
- const sceneY = midPoint.y;
- this.startTextEditing({
- sceneX,
- sceneY,
- container,
- });
- event.preventDefault();
- return;
- } else if (isFrameLikeElement(selectedElement)) {
- this.setState({
- editingFrame: selectedElement.id,
- });
- }
- }
- } else if (
- !event.ctrlKey &&
- !event.altKey &&
- !event.metaKey &&
- !this.state.newElement &&
- !this.state.selectionElement &&
- !this.state.selectedElementsAreBeingDragged
- ) {
- const shape = findShapeByKey(event.key);
- if (shape) {
- if (this.state.activeTool.type !== shape) {
- trackEvent(
- "toolbar",
- shape,
- `keyboard (${
- this.device.editor.isMobile ? "mobile" : "desktop"
- })`,
- );
- }
- if (shape === "arrow" && this.state.activeTool.type === "arrow") {
- this.setState((prevState) => ({
- currentItemArrowType:
- prevState.currentItemArrowType === ARROW_TYPE.sharp
- ? ARROW_TYPE.round
- : prevState.currentItemArrowType === ARROW_TYPE.round
- ? ARROW_TYPE.elbow
- : ARROW_TYPE.sharp,
- }));
- }
- this.setActiveTool({ type: shape });
- event.stopPropagation();
- } else if (event.key === KEYS.Q) {
- this.toggleLock("keyboard");
- event.stopPropagation();
- }
- }
- if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
- isHoldingSpace = true;
- setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
- event.preventDefault();
- }
- if (
- (event.key === KEYS.G || event.key === KEYS.S) &&
- !event.altKey &&
- !event[KEYS.CTRL_OR_CMD]
- ) {
- const selectedElements = this.scene.getSelectedElements(this.state);
- if (
- this.state.activeTool.type === "selection" &&
- !selectedElements.length
- ) {
- return;
- }
- if (
- event.key === KEYS.G &&
- (hasBackground(this.state.activeTool.type) ||
- selectedElements.some((element) => hasBackground(element.type)))
- ) {
- this.setState({ openPopup: "elementBackground" });
- event.stopPropagation();
- }
- if (event.key === KEYS.S) {
- this.setState({ openPopup: "elementStroke" });
- event.stopPropagation();
- }
- }
- if (
- !event[KEYS.CTRL_OR_CMD] &&
- event.shiftKey &&
- event.key.toLowerCase() === KEYS.F
- ) {
- const selectedElements = this.scene.getSelectedElements(this.state);
- if (
- this.state.activeTool.type === "selection" &&
- !selectedElements.length
- ) {
- return;
- }
- if (
- this.state.activeTool.type === "text" ||
- selectedElements.find(
- (element) =>
- isTextElement(element) ||
- getBoundTextElement(
- element,
- this.scene.getNonDeletedElementsMap(),
- ),
- )
- ) {
- event.preventDefault();
- this.setState({ openPopup: "fontFamily" });
- }
- }
- if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
- if (this.state.activeTool.type === "laser") {
- this.setActiveTool({ type: "selection" });
- } else {
- this.setActiveTool({ type: "laser" });
- }
- return;
- }
- if (
- event[KEYS.CTRL_OR_CMD] &&
- (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
- ) {
- this.updateEditorAtom(activeConfirmDialogAtom, "clearCanvas");
- }
- // eye dropper
- // -----------------------------------------------------------------------
- const lowerCased = event.key.toLocaleLowerCase();
- const isPickingStroke = lowerCased === KEYS.S && event.shiftKey;
- const isPickingBackground =
- event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey);
- if (isPickingStroke || isPickingBackground) {
- this.openEyeDropper({
- type: isPickingStroke ? "stroke" : "background",
- });
- }
- // -----------------------------------------------------------------------
- },
- );
- private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
- if (event.key === KEYS.SPACE) {
- if (
- this.state.viewModeEnabled ||
- this.state.openDialog?.name === "elementLinkSelector"
- ) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
- } else if (
- this.state.activeTool.type === "selection" ||
- this.state.activeTool.type === "lasso"
- ) {
- resetCursor(this.interactiveCanvas);
- } else {
- setCursorForShape(this.interactiveCanvas, this.state);
- this.setState({
- selectedElementIds: makeNextSelectedElementIds({}, this.state),
- selectedGroupIds: {},
- editingGroupId: null,
- activeEmbeddable: null,
- });
- }
- isHoldingSpace = false;
- }
- if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
- this.setState({ isBindingEnabled: true });
- }
- if (isArrowKey(event.key)) {
- bindOrUnbindLinearElements(
- this.scene.getSelectedElements(this.state).filter(isLinearElement),
- isBindingEnabled(this.state),
- this.state.selectedLinearElement?.selectedPointsIndices ?? [],
- this.scene,
- this.state.zoom,
- );
- this.setState({ suggestedBindings: [] });
- }
- if (!event.altKey) {
- if (this.flowChartNavigator.isExploring) {
- this.flowChartNavigator.clear();
- this.syncActionResult({
- captureUpdate: CaptureUpdateAction.IMMEDIATELY,
- });
- }
- }
- if (!event[KEYS.CTRL_OR_CMD]) {
- if (this.flowChartCreator.isCreatingChart) {
- if (this.flowChartCreator.pendingNodes?.length) {
- this.scene.insertElements(this.flowChartCreator.pendingNodes);
- }
- const firstNode = this.flowChartCreator.pendingNodes?.[0];
- if (firstNode) {
- this.setState((prevState) => ({
- selectedElementIds: makeNextSelectedElementIds(
- {
- [firstNode.id]: true,
- },
- prevState,
- ),
- }));
- if (
- !isElementCompletelyInViewport(
- [firstNode],
- this.canvas.width / window.devicePixelRatio,
- this.canvas.height / window.devicePixelRatio,
- {
- offsetLeft: this.state.offsetLeft,
- offsetTop: this.state.offsetTop,
- scrollX: this.state.scrollX,
- scrollY: this.state.scrollY,
- zoom: this.state.zoom,
- },
- this.scene.getNonDeletedElementsMap(),
- this.getEditorUIOffsets(),
- )
- ) {
- this.scrollToContent(firstNode, {
- animate: true,
- duration: 300,
- canvasOffsets: this.getEditorUIOffsets(),
- });
- }
- }
- this.flowChartCreator.clear();
- this.syncActionResult({
- captureUpdate: CaptureUpdateAction.IMMEDIATELY,
- });
- }
- }
- });
- // We purposely widen the `tool` type so this helper can be called with
- // any tool without having to type check it
- private isToolSupported = <T extends ToolType | "custom">(tool: T) => {
- return (
- this.props.UIOptions.tools?.[
- tool as Extract<T, keyof AppProps["UIOptions"]["tools"]>
- ] !== false
- );
- };
- setActiveTool = (
- tool: ({ type: ToolType } | { type: "custom"; customType: string }) & {
- locked?: boolean;
- fromSelection?: boolean;
- },
- keepSelection = false,
- ) => {
- if (!this.isToolSupported(tool.type)) {
- console.warn(
- `"${tool.type}" tool is disabled via "UIOptions.canvasActions.tools.${tool.type}"`,
- );
- return;
- }
- const nextActiveTool = updateActiveTool(this.state, tool);
- if (nextActiveTool.type === "hand") {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
- } else if (!isHoldingSpace) {
- setCursorForShape(this.interactiveCanvas, {
- ...this.state,
- activeTool: nextActiveTool,
- });
- }
- if (isToolIcon(document.activeElement)) {
- this.focusContainer();
- }
- if (!isLinearElementType(nextActiveTool.type)) {
- this.setState({ suggestedBindings: [] });
- }
- if (nextActiveTool.type === "image") {
- this.onImageAction();
- }
- this.setState((prevState) => {
- const commonResets = {
- snapLines: prevState.snapLines.length ? [] : prevState.snapLines,
- originSnapOffset: null,
- activeEmbeddable: null,
- } as const;
- if (nextActiveTool.type === "freedraw") {
- this.store.scheduleCapture();
- }
- if (nextActiveTool.type === "lasso") {
- return {
- ...prevState,
- activeTool: nextActiveTool,
- ...(keepSelection
- ? {}
- : {
- selectedElementIds: makeNextSelectedElementIds({}, prevState),
- selectedGroupIds: makeNextSelectedElementIds({}, prevState),
- editingGroupId: null,
- multiElement: null,
- }),
- ...commonResets,
- };
- } else if (nextActiveTool.type !== "selection") {
- return {
- ...prevState,
- activeTool: nextActiveTool,
- selectedElementIds: makeNextSelectedElementIds({}, prevState),
- selectedGroupIds: makeNextSelectedElementIds({}, prevState),
- editingGroupId: null,
- multiElement: null,
- ...commonResets,
- };
- }
- return {
- ...prevState,
- activeTool: nextActiveTool,
- ...commonResets,
- };
- });
- };
- setOpenDialog = (dialogType: AppState["openDialog"]) => {
- this.setState({ openDialog: dialogType });
- };
- private setCursor = (cursor: string) => {
- setCursor(this.interactiveCanvas, cursor);
- };
- private resetCursor = () => {
- resetCursor(this.interactiveCanvas);
- };
- /**
- * returns whether user is making a gesture with >= 2 fingers (points)
- * on o touch screen (not on a trackpad). Currently only relates to Darwin
- * (iOS/iPadOS,MacOS), but may work on other devices in the future if
- * GestureEvent is standardized.
- */
- private isTouchScreenMultiTouchGesture = () => {
- // we don't want to deselect when using trackpad, and multi-point gestures
- // only work on touch screens, so checking for >= pointers means we're on a
- // touchscreen
- return gesture.pointers.size >= 2;
- };
- public getName = () => {
- return (
- this.state.name ||
- this.props.name ||
- `${t("labels.untitled")}-${getDateTime()}`
- );
- };
- // fires only on Safari
- private onGestureStart = withBatchedUpdates((event: GestureEvent) => {
- event.preventDefault();
- // we only want to deselect on touch screens because user may have selected
- // elements by mistake while zooming
- if (this.isTouchScreenMultiTouchGesture()) {
- this.setState({
- selectedElementIds: makeNextSelectedElementIds({}, this.state),
- activeEmbeddable: null,
- });
- }
- gesture.initialScale = this.state.zoom.value;
- });
- // fires only on Safari
- private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
- event.preventDefault();
- // onGestureChange only has zoom factor but not the center.
- // If we're on iPad or iPhone, then we recognize multi-touch and will
- // zoom in at the right location in the touchmove handler
- // (handleCanvasPointerMove).
- //
- // On Macbook trackpad, we don't have those events so will zoom in at the
- // current location instead.
- //
- // As such, bail from this handler on touch devices.
- if (this.isTouchScreenMultiTouchGesture()) {
- return;
- }
- const initialScale = gesture.initialScale;
- if (initialScale) {
- this.setState((state) => ({
- ...getStateForZoom(
- {
- viewportX: this.lastViewportPosition.x,
- viewportY: this.lastViewportPosition.y,
- nextZoom: getNormalizedZoom(initialScale * event.scale),
- },
- state,
- ),
- }));
- }
- });
- // fires only on Safari
- private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
- event.preventDefault();
- // reselect elements only on touch screens (see onGestureStart)
- if (this.isTouchScreenMultiTouchGesture()) {
- this.setState({
- previousSelectedElementIds: {},
- selectedElementIds: makeNextSelectedElementIds(
- this.state.previousSelectedElementIds,
- this.state,
- ),
- });
- }
- gesture.initialScale = null;
- });
- private handleTextWysiwyg(
- element: ExcalidrawTextElement,
- {
- isExistingElement = false,
- }: {
- isExistingElement?: boolean;
- },
- ) {
- const elementsMap = this.scene.getElementsMapIncludingDeleted();
- const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
- this.scene.replaceAllElements([
- // Not sure why we include deleted elements as well hence using deleted elements map
- ...this.scene.getElementsIncludingDeleted().map((_element) => {
- if (_element.id === element.id && isTextElement(_element)) {
- return newElementWith(_element, {
- originalText: nextOriginalText,
- isDeleted: isDeleted ?? _element.isDeleted,
- // returns (wrapped) text and new dimensions
- ...refreshTextDimensions(
- _element,
- getContainerElement(_element, elementsMap),
- elementsMap,
- nextOriginalText,
- ),
- });
- }
- return _element;
- }),
- ]);
- };
- textWysiwyg({
- id: element.id,
- canvas: this.canvas,
- getViewportCoords: (x, y) => {
- const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
- {
- sceneX: x,
- sceneY: y,
- },
- this.state,
- );
- return [
- viewportX - this.state.offsetLeft,
- viewportY - this.state.offsetTop,
- ];
- },
- onChange: withBatchedUpdates((nextOriginalText) => {
- updateElement(nextOriginalText, false);
- if (isNonDeletedElement(element)) {
- updateBoundElements(element, this.scene);
- }
- }),
- onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
- const isDeleted = !nextOriginalText.trim();
- if (isDeleted && !isExistingElement) {
- // let's just remove the element from the scene, as it's an empty just created text element
- this.scene.replaceAllElements(
- this.scene
- .getElementsIncludingDeleted()
- .filter((x) => x.id !== element.id),
- );
- } else {
- updateElement(nextOriginalText, isDeleted);
- }
- // select the created text element only if submitting via keyboard
- // (when submitting via click it should act as signal to deselect)
- if (!isDeleted && viaKeyboard) {
- const elementIdToSelect = element.containerId
- ? element.containerId
- : element.id;
- // needed to ensure state is updated before "finalize" action
- // that's invoked on keyboard-submit as well
- // TODO either move this into finalize as well, or handle all state
- // updates in one place, skipping finalize action
- flushSync(() => {
- this.setState((prevState) => ({
- selectedElementIds: makeNextSelectedElementIds(
- {
- ...prevState.selectedElementIds,
- [elementIdToSelect]: true,
- },
- prevState,
- ),
- }));
- });
- }
- if (isDeleted) {
- fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [
- element,
- ]);
- }
- // we need to record either way, whether the text element was added or removed
- // since we need to sync this delta to other clients, otherwise it would end up with inconsistencies
- this.store.scheduleCapture();
- flushSync(() => {
- this.setState({
- newElement: null,
- editingTextElement: null,
- });
- });
- if (this.state.activeTool.locked) {
- setCursorForShape(this.interactiveCanvas, this.state);
- }
- this.focusContainer();
- }),
- element,
- excalidrawContainer: this.excalidrawContainerRef.current,
- app: this,
- // when text is selected, it's hard (at least on iOS) to re-position the
- // caret (i.e. deselect). There's not much use for always selecting
- // the text on edit anyway (and users can select-all from contextmenu
- // if needed)
- autoSelect: !this.device.isTouchScreen,
- });
- // deselect all other elements when inserting text
- this.deselectElements();
- // do an initial update to re-initialize element position since we were
- // modifying element's x/y for sake of editor (case: syncing to remote)
- updateElement(element.originalText, false);
- }
- private deselectElements() {
- this.setState({
- selectedElementIds: makeNextSelectedElementIds({}, this.state),
- selectedGroupIds: {},
- editingGroupId: null,
- activeEmbeddable: null,
- });
- }
- private getTextElementAtPosition(
- x: number,
- y: number,
- ): NonDeleted<ExcalidrawTextElement> | null {
- const element = this.getElementAtPosition(x, y, {
- includeBoundTextElement: true,
- });
- if (element && isTextElement(element) && !element.isDeleted) {
- return element;
- }
- return null;
- }
- // NOTE: Hot path for hit testing, so avoid unnecessary computations
- private getElementAtPosition(
- x: number,
- y: number,
- opts?: (
- | {
- includeBoundTextElement?: boolean;
- includeLockedElements?: boolean;
- }
- | {
- allHitElements: NonDeleted<ExcalidrawElement>[];
- }
- ) & {
- preferSelected?: boolean;
- },
- ): NonDeleted<ExcalidrawElement> | null {
- let allHitElements: NonDeleted<ExcalidrawElement>[] = [];
- if (opts && "allHitElements" in opts) {
- allHitElements = opts?.allHitElements || [];
- } else {
- allHitElements = this.getElementsAtPosition(x, y, {
- includeBoundTextElement: opts?.includeBoundTextElement,
- includeLockedElements: opts?.includeLockedElements,
- });
- }
- if (allHitElements.length > 1) {
- if (opts?.preferSelected) {
- for (let index = allHitElements.length - 1; index > -1; index--) {
- if (this.state.selectedElementIds[allHitElements[index].id]) {
- return allHitElements[index];
- }
- }
- }
- const elementWithHighestZIndex =
- allHitElements[allHitElements.length - 1];
- // If we're hitting element with highest z-index only on its bounding box
- // while also hitting other element figure, the latter should be considered.
- return hitElementItself({
- point: pointFrom(x, y),
- element: elementWithHighestZIndex,
- // when overlapping, we would like to be more precise
- // this also avoids the need to update past tests
- threshold: this.getElementHitThreshold(elementWithHighestZIndex) / 2,
- elementsMap: this.scene.getNonDeletedElementsMap(),
- frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
- ? this.frameNameBoundsCache.get(elementWithHighestZIndex)
- : null,
- })
- ? elementWithHighestZIndex
- : allHitElements[allHitElements.length - 2];
- }
- if (allHitElements.length === 1) {
- return allHitElements[0];
- }
- return null;
- }
- // NOTE: Hot path for hit testing, so avoid unnecessary computations
- private getElementsAtPosition(
- x: number,
- y: number,
- opts?: {
- includeBoundTextElement?: boolean;
- includeLockedElements?: boolean;
- },
- ): NonDeleted<ExcalidrawElement>[] {
- const iframeLikes: Ordered<ExcalidrawIframeElement>[] = [];
- const elementsMap = this.scene.getNonDeletedElementsMap();
- const elements = (
- opts?.includeBoundTextElement && opts?.includeLockedElements
- ? this.scene.getNonDeletedElements()
- : this.scene
- .getNonDeletedElements()
- .filter(
- (element) =>
- (opts?.includeLockedElements || !element.locked) &&
- (opts?.includeBoundTextElement ||
- !(isTextElement(element) && element.containerId)),
- )
- )
- .filter((el) => this.hitElement(x, y, el))
- .filter((element) => {
- // hitting a frame's element from outside the frame is not considered a hit
- const containingFrame = getContainingFrame(element, elementsMap);
- return containingFrame &&
- this.state.frameRendering.enabled &&
- this.state.frameRendering.clip
- ? isCursorInFrame({ x, y }, containingFrame, elementsMap)
- : true;
- })
- .filter((el) => {
- // The parameter elements comes ordered from lower z-index to higher.
- // We want to preserve that order on the returned array.
- // Exception being embeddables which should be on top of everything else in
- // terms of hit testing.
- if (isIframeElement(el)) {
- iframeLikes.push(el);
- return false;
- }
- return true;
- })
- .concat(iframeLikes) as NonDeleted<ExcalidrawElement>[];
- return elements;
- }
- getElementHitThreshold(element: ExcalidrawElement) {
- return Math.max(
- element.strokeWidth / 2 + 0.1,
- // NOTE: Here be dragons. Do not go under the 0.63 multiplier unless you're
- // willing to test extensively. The hit testing starts to become unreliable
- // due to FP imprecision under 0.63 in high zoom levels.
- 0.85 * (DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value),
- );
- }
- private hitElement(
- x: number,
- y: number,
- element: ExcalidrawElement,
- considerBoundingBox = true,
- ) {
- // if the element is selected, then hit test is done against its bounding box
- if (
- considerBoundingBox &&
- this.state.selectedElementIds[element.id] &&
- shouldShowBoundingBox([element], this.state)
- ) {
- // if hitting the bounding box, return early
- // but if not, we should check for other cases as well (e.g. frame name)
- if (
- hitElementBoundingBox(
- pointFrom(x, y),
- element,
- this.scene.getNonDeletedElementsMap(),
- this.getElementHitThreshold(element),
- )
- ) {
- return true;
- }
- }
- // take bound text element into consideration for hit collision as well
- const hitBoundTextOfElement = hitElementBoundText(
- pointFrom(x, y),
- element,
- this.scene.getNonDeletedElementsMap(),
- );
- if (hitBoundTextOfElement) {
- return true;
- }
- return hitElementItself({
- point: pointFrom(x, y),
- element,
- threshold: this.getElementHitThreshold(element),
- elementsMap: this.scene.getNonDeletedElementsMap(),
- frameNameBound: isFrameLikeElement(element)
- ? this.frameNameBoundsCache.get(element)
- : null,
- });
- }
- private getTextBindableContainerAtPosition(x: number, y: number) {
- const elements = this.scene.getNonDeletedElements();
- const selectedElements = this.scene.getSelectedElements(this.state);
- if (selectedElements.length === 1) {
- return isTextBindableContainer(selectedElements[0], false)
- ? selectedElements[0]
- : null;
- }
- let hitElement = null;
- // We need to do hit testing from front (end of the array) to back (beginning of the array)
- for (let index = elements.length - 1; index >= 0; --index) {
- if (elements[index].isDeleted) {
- continue;
- }
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(
- elements[index],
- this.scene.getNonDeletedElementsMap(),
- );
- if (
- isArrowElement(elements[index]) &&
- hitElementItself({
- point: pointFrom(x, y),
- element: elements[index],
- elementsMap: this.scene.getNonDeletedElementsMap(),
- threshold: this.getElementHitThreshold(elements[index]),
- })
- ) {
- hitElement = elements[index];
- break;
- } else if (x1 < x && x < x2 && y1 < y && y < y2) {
- hitElement = elements[index];
- break;
- }
- }
- return isTextBindableContainer(hitElement, false) ? hitElement : null;
- }
- private startTextEditing = ({
- sceneX,
- sceneY,
- insertAtParentCenter = true,
- container,
- autoEdit = true,
- }: {
- /** X position to insert text at */
- sceneX: number;
- /** Y position to insert text at */
- sceneY: number;
- /** whether to attempt to insert at element center if applicable */
- insertAtParentCenter?: boolean;
- container?: ExcalidrawTextContainer | null;
- autoEdit?: boolean;
- }) => {
- let shouldBindToContainer = false;
- let parentCenterPosition =
- insertAtParentCenter &&
- this.getTextWysiwygSnappedToCenterPosition(
- sceneX,
- sceneY,
- this.state,
- container,
- );
- if (container && parentCenterPosition) {
- const boundTextElementToContainer = getBoundTextElement(
- container,
- this.scene.getNonDeletedElementsMap(),
- );
- if (!boundTextElementToContainer) {
- shouldBindToContainer = true;
- }
- }
- let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
- const selectedElements = this.scene.getSelectedElements(this.state);
- if (selectedElements.length === 1) {
- if (isTextElement(selectedElements[0])) {
- existingTextElement = selectedElements[0];
- } else if (container) {
- existingTextElement = getBoundTextElement(
- selectedElements[0],
- this.scene.getNonDeletedElementsMap(),
- );
- } else {
- existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
- }
- } else {
- existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
- }
- const fontFamily =
- existingTextElement?.fontFamily || this.state.currentItemFontFamily;
- const lineHeight =
- existingTextElement?.lineHeight || getLineHeight(fontFamily);
- const fontSize = this.state.currentItemFontSize;
- if (
- !existingTextElement &&
- shouldBindToContainer &&
- container &&
- !isArrowElement(container)
- ) {
- const fontString = {
- fontSize,
- fontFamily,
- };
- const minWidth = getApproxMinLineWidth(
- getFontString(fontString),
- lineHeight,
- );
- const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
- const newHeight = Math.max(container.height, minHeight);
- const newWidth = Math.max(container.width, minWidth);
- this.scene.mutateElement(container, {
- height: newHeight,
- width: newWidth,
- });
- sceneX = container.x + newWidth / 2;
- sceneY = container.y + newHeight / 2;
- if (parentCenterPosition) {
- parentCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
- sceneX,
- sceneY,
- this.state,
- container,
- );
- }
- }
- const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
- x: sceneX,
- y: sceneY,
- });
- const element =
- existingTextElement ||
- newTextElement({
- x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX,
- y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY,
- strokeColor: this.state.currentItemStrokeColor,
- backgroundColor: this.state.currentItemBackgroundColor,
- fillStyle: this.state.currentItemFillStyle,
- strokeWidth: this.state.currentItemStrokeWidth,
- strokeStyle: this.state.currentItemStrokeStyle,
- roughness: this.state.currentItemRoughness,
- opacity: this.state.currentItemOpacity,
- text: "",
- fontSize,
- fontFamily,
- textAlign: parentCenterPosition
- ? "center"
- : this.state.currentItemTextAlign,
- verticalAlign: parentCenterPosition
- ? VERTICAL_ALIGN.MIDDLE
- : DEFAULT_VERTICAL_ALIGN,
- containerId: shouldBindToContainer ? container?.id : undefined,
- groupIds: container?.groupIds ?? [],
- lineHeight,
- angle: container
- ? isArrowElement(container)
- ? (0 as Radians)
- : container.angle
- : (0 as Radians),
- frameId: topLayerFrame ? topLayerFrame.id : null,
- });
- if (!existingTextElement && shouldBindToContainer && container) {
- this.scene.mutateElement(container, {
- boundElements: (container.boundElements || []).concat({
- type: "text",
- id: element.id,
- }),
- });
- }
- this.setState({ editingTextElement: element });
- if (!existingTextElement) {
- if (container && shouldBindToContainer) {
- const containerIndex = this.scene.getElementIndex(container.id);
- this.scene.insertElementAtIndex(element, containerIndex + 1);
- } else {
- this.scene.insertElement(element);
- }
- }
- if (autoEdit || existingTextElement || container) {
- this.handleTextWysiwyg(element, {
- isExistingElement: !!existingTextElement,
- });
- } else {
- this.setState({
- newElement: element,
- multiElement: null,
- });
- }
- };
- private startImageCropping = (image: ExcalidrawImageElement) => {
- this.store.scheduleCapture();
- this.setState({
- croppingElementId: image.id,
- });
- };
- private finishImageCropping = () => {
- if (this.state.croppingElementId) {
- this.store.scheduleCapture();
- this.setState({
- croppingElementId: null,
- });
- }
- };
- private handleCanvasDoubleClick = (
- event: React.MouseEvent<HTMLCanvasElement>,
- ) => {
- // case: double-clicking with arrow/line tool selected would both create
- // text and enter multiElement mode
- if (this.state.multiElement) {
- return;
- }
- // we should only be able to double click when mode is selection
- if (this.state.activeTool.type !== "selection") {
- return;
- }
- const selectedElements = this.scene.getSelectedElements(this.state);
- let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
- event,
- this.state,
- );
- if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
- const selectedLinearElement: ExcalidrawLinearElement =
- selectedElements[0];
- if (
- ((event[KEYS.CTRL_OR_CMD] && isSimpleArrow(selectedLinearElement)) ||
- isLineElement(selectedLinearElement)) &&
- (!this.state.selectedLinearElement?.isEditing ||
- this.state.selectedLinearElement.elementId !==
- selectedLinearElement.id)
- ) {
- // Use the proper action to ensure immediate history capture
- this.actionManager.executeAction(actionToggleLinearEditor);
- return;
- } else if (
- this.state.selectedLinearElement &&
- isElbowArrow(selectedElements[0])
- ) {
- const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
- this.state.selectedLinearElement,
- { x: sceneX, y: sceneY },
- this.state,
- this.scene.getNonDeletedElementsMap(),
- );
- const midPoint = hitCoords
- ? LinearElementEditor.getSegmentMidPointIndex(
- this.state.selectedLinearElement,
- this.state,
- hitCoords,
- this.scene.getNonDeletedElementsMap(),
- )
- : -1;
- if (midPoint && midPoint > -1) {
- this.store.scheduleCapture();
- LinearElementEditor.deleteFixedSegment(
- selectedElements[0],
- this.scene,
- midPoint,
- );
- const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
- {
- ...this.state.selectedLinearElement,
- segmentMidPointHoveredCoords: null,
- },
- { x: sceneX, y: sceneY },
- this.state,
- this.scene.getNonDeletedElementsMap(),
- );
- const nextIndex = nextCoords
- ? LinearElementEditor.getSegmentMidPointIndex(
- this.state.selectedLinearElement,
- this.state,
- nextCoords,
- this.scene.getNonDeletedElementsMap(),
- )
- : null;
- this.setState({
- selectedLinearElement: {
- ...this.state.selectedLinearElement,
- pointerDownState: {
- ...this.state.selectedLinearElement.pointerDownState,
- segmentMidpoint: {
- index: nextIndex,
- value: hitCoords,
- added: false,
- },
- },
- segmentMidPointHoveredCoords: nextCoords,
- },
- });
- return;
- }
- } else if (
- this.state.selectedLinearElement?.isEditing &&
- this.state.selectedLinearElement.elementId ===
- selectedLinearElement.id &&
- isLineElement(selectedLinearElement)
- ) {
- return;
- }
- }
- if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
- this.startImageCropping(selectedElements[0]);
- return;
- }
- resetCursor(this.interactiveCanvas);
- const selectedGroupIds = getSelectedGroupIds(this.state);
- if (selectedGroupIds.length > 0) {
- const hitElement = this.getElementAtPosition(sceneX, sceneY);
- const selectedGroupId =
- hitElement &&
- getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
- if (selectedGroupId) {
- this.store.scheduleCapture();
- this.setState((prevState) => ({
- ...prevState,
- ...selectGroupsForSelectedElements(
- {
- editingGroupId: selectedGroupId,
- selectedElementIds: { [hitElement!.id]: true },
- },
- this.scene.getNonDeletedElements(),
- prevState,
- this,
- ),
- }));
- return;
- }
- }
- resetCursor(this.interactiveCanvas);
- if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
- const hitElement = this.getElementAtPosition(sceneX, sceneY);
- if (isIframeLikeElement(hitElement)) {
- this.setState({
- activeEmbeddable: { element: hitElement, state: "active" },
- });
- return;
- }
- // shouldn't edit/create text when inside line editor (often false positive)
- if (!this.state.selectedLinearElement?.isEditing) {
- const container = this.getTextBindableContainerAtPosition(
- sceneX,
- sceneY,
- );
- if (container) {
- if (
- hasBoundTextElement(container) ||
- !isTransparent(container.backgroundColor) ||
- hitElementItself({
- point: pointFrom(sceneX, sceneY),
- element: container,
- elementsMap: this.scene.getNonDeletedElementsMap(),
- threshold: this.getElementHitThreshold(container),
- })
- ) {
- const midPoint = getContainerCenter(
- container,
- this.state,
- this.scene.getNonDeletedElementsMap(),
- );
- sceneX = midPoint.x;
- sceneY = midPoint.y;
- }
- }
- this.startTextEditing({
- sceneX,
- sceneY,
- insertAtParentCenter: !event.altKey,
- container,
- });
- }
- }
- };
- private getElementLinkAtPosition = (
- scenePointer: Readonly<{ x: number; y: number }>,
- hitElementMightBeLocked: NonDeletedExcalidrawElement | null,
- ): ExcalidrawElement | undefined => {
- if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
- return undefined;
- }
- const elements = this.scene.getNonDeletedElements();
- let hitElementIndex = -1;
- for (let index = elements.length - 1; index >= 0; index--) {
- const element = elements[index];
- if (
- hitElementMightBeLocked &&
- element.id === hitElementMightBeLocked.id
- ) {
- hitElementIndex = index;
- }
- if (
- element.link &&
- index >= hitElementIndex &&
- isPointHittingLink(
- element,
- this.scene.getNonDeletedElementsMap(),
- this.state,
- pointFrom(scenePointer.x, scenePointer.y),
- this.device.editor.isMobile,
- )
- ) {
- return element;
- }
- }
- };
- private redirectToLink = (
- event: React.PointerEvent<HTMLCanvasElement>,
- isTouchScreen: boolean,
- ) => {
- const draggedDistance = pointDistance(
- pointFrom(
- this.lastPointerDownEvent!.clientX,
- this.lastPointerDownEvent!.clientY,
- ),
- pointFrom(
- this.lastPointerUpEvent!.clientX,
- this.lastPointerUpEvent!.clientY,
- ),
- );
- if (!this.hitLinkElement || draggedDistance > DRAGGING_THRESHOLD) {
- return;
- }
- const lastPointerDownCoords = viewportCoordsToSceneCoords(
- this.lastPointerDownEvent!,
- this.state,
- );
- const elementsMap = this.scene.getNonDeletedElementsMap();
- const lastPointerDownHittingLinkIcon = isPointHittingLink(
- this.hitLinkElement,
- elementsMap,
- this.state,
- pointFrom(lastPointerDownCoords.x, lastPointerDownCoords.y),
- this.device.editor.isMobile,
- );
- const lastPointerUpCoords = viewportCoordsToSceneCoords(
- this.lastPointerUpEvent!,
- this.state,
- );
- const lastPointerUpHittingLinkIcon = isPointHittingLink(
- this.hitLinkElement,
- elementsMap,
- this.state,
- pointFrom(lastPointerUpCoords.x, lastPointerUpCoords.y),
- this.device.editor.isMobile,
- );
- if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
- hideHyperlinkToolip();
- let url = this.hitLinkElement.link;
- if (url) {
- url = normalizeLink(url);
- let customEvent;
- if (this.props.onLinkOpen) {
- customEvent = wrapEvent(EVENT.EXCALIDRAW_LINK, event.nativeEvent);
- this.props.onLinkOpen(
- {
- ...this.hitLinkElement,
- link: url,
- },
- customEvent,
- );
- }
- if (!customEvent?.defaultPrevented) {
- const target = isLocalLink(url) ? "_self" : "_blank";
- const newWindow = window.open(undefined, target);
- // https://mathiasbynens.github.io/rel-noopener/
- if (newWindow) {
- newWindow.opener = null;
- newWindow.location = url;
- }
- }
- }
- }
- };
- private getTopLayerFrameAtSceneCoords = (sceneCoords: {
- x: number;
- y: number;
- }) => {
- const elementsMap = this.scene.getNonDeletedElementsMap();
- const frames = this.scene
- .getNonDeletedFramesLikes()
- .filter((frame): frame is ExcalidrawFrameLikeElement =>
- isCursorInFrame(sceneCoords, frame, elementsMap),
- );
- return frames.length ? frames[frames.length - 1] : null;
- };
- private handleCanvasPointerMove = (
- event: React.PointerEvent<HTMLCanvasElement>,
- ) => {
- this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
- this.lastPointerMoveEvent = event.nativeEvent;
- if (gesture.pointers.has(event.pointerId)) {
- gesture.pointers.set(event.pointerId, {
- x: event.clientX,
- y: event.clientY,
- });
- }
- const initialScale = gesture.initialScale;
- if (
- gesture.pointers.size === 2 &&
- gesture.lastCenter &&
- initialScale &&
- gesture.initialDistance
- ) {
- 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(Array.from(gesture.pointers.values()));
- const scaleFactor =
- this.state.activeTool.type === "freedraw" && this.state.penMode
- ? 1
- : distance / gesture.initialDistance;
- const nextZoom = scaleFactor
- ? getNormalizedZoom(initialScale * scaleFactor)
- : this.state.zoom.value;
- this.setState((state) => {
- const zoomState = getStateForZoom(
- {
- viewportX: center.x,
- viewportY: center.y,
- nextZoom,
- },
- state,
- );
- this.translateCanvas({
- zoom: zoomState.zoom,
- // 2x multiplier is just a magic number that makes this work correctly
- // on touchscreen devices (note: if we get report that panning is slower/faster
- // than actual movement, consider swapping with devicePixelRatio)
- scrollX: zoomState.scrollX + 2 * (deltaX / nextZoom),
- scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom),
- shouldCacheIgnoreZoom: true,
- });
- });
- this.resetShouldCacheIgnoreZoomDebounced();
- } else {
- gesture.lastCenter =
- gesture.initialDistance =
- gesture.initialScale =
- null;
- }
- if (
- isHoldingSpace ||
- isPanning ||
- isDraggingScrollBar ||
- isHandToolActive(this.state)
- ) {
- return;
- }
- const isPointerOverScrollBars = isOverScrollBars(
- currentScrollBars,
- event.clientX - this.state.offsetLeft,
- event.clientY - this.state.offsetTop,
- );
- const isOverScrollBar = isPointerOverScrollBars.isOverEither;
- if (
- !this.state.newElement &&
- !this.state.selectionElement &&
- !this.state.selectedElementsAreBeingDragged &&
- !this.state.multiElement
- ) {
- if (isOverScrollBar) {
- resetCursor(this.interactiveCanvas);
- } else {
- setCursorForShape(this.interactiveCanvas, this.state);
- }
- }
- const scenePointer = viewportCoordsToSceneCoords(event, this.state);
- const { x: scenePointerX, y: scenePointerY } = scenePointer;
- if (
- !this.state.newElement &&
- isActiveToolNonLinearSnappable(this.state.activeTool.type)
- ) {
- const { originOffset, snapLines } = getSnapLinesAtPointer(
- this.scene.getNonDeletedElements(),
- this,
- {
- x: scenePointerX,
- y: scenePointerY,
- },
- event,
- this.scene.getNonDeletedElementsMap(),
- );
- this.setState((prevState) => {
- const nextSnapLines = updateStable(prevState.snapLines, snapLines);
- const nextOriginOffset = prevState.originSnapOffset
- ? updateStable(prevState.originSnapOffset, originOffset)
- : originOffset;
- if (
- prevState.snapLines === nextSnapLines &&
- prevState.originSnapOffset === nextOriginOffset
- ) {
- return null;
- }
- return {
- snapLines: nextSnapLines,
- originSnapOffset: nextOriginOffset,
- };
- });
- } else if (
- !this.state.newElement &&
- !this.state.selectedElementsAreBeingDragged &&
- !this.state.selectionElement
- ) {
- this.setState((prevState) => {
- if (prevState.snapLines.length) {
- return {
- snapLines: [],
- };
- }
- return null;
- });
- }
- if (
- this.state.selectedLinearElement?.isEditing &&
- !this.state.selectedLinearElement.isDragging
- ) {
- const editingLinearElement = LinearElementEditor.handlePointerMove(
- event,
- scenePointerX,
- scenePointerY,
- this,
- );
- const linearElement = editingLinearElement
- ? this.scene.getElement(editingLinearElement.elementId)
- : null;
- if (
- editingLinearElement &&
- editingLinearElement !== this.state.selectedLinearElement
- ) {
- // Since we are reading from previous state which is not possible with
- // automatic batching in React 18 hence using flush sync to synchronously
- // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
- flushSync(() => {
- this.setState({
- selectedLinearElement: editingLinearElement,
- });
- });
- }
- if (
- editingLinearElement?.lastUncommittedPoint != null &&
- linearElement &&
- isBindingElementType(linearElement.type)
- ) {
- this.maybeSuggestBindingAtCursor(
- scenePointer,
- editingLinearElement.elbowed,
- );
- } else if (this.state.suggestedBindings.length) {
- this.setState({ suggestedBindings: [] });
- }
- }
- if (isBindingElementType(this.state.activeTool.type)) {
- // Hovering with a selected tool or creating new linear element via click
- // and point
- const { newElement } = this.state;
- if (isBindingElement(newElement, false)) {
- this.setState({
- suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords(
- newElement,
- [scenePointer],
- this.scene,
- this.state.zoom,
- this.state.startBoundElement,
- ),
- });
- } else {
- this.maybeSuggestBindingAtCursor(scenePointer, false);
- }
- }
- if (this.state.multiElement) {
- const { multiElement } = this.state;
- const { x: rx, y: ry } = multiElement;
- const { points, lastCommittedPoint } = multiElement;
- const lastPoint = points[points.length - 1];
- setCursorForShape(this.interactiveCanvas, this.state);
- if (lastPoint === lastCommittedPoint) {
- // if we haven't yet created a temp point and we're beyond commit-zone
- // threshold, add a point
- if (
- pointDistance(
- pointFrom(scenePointerX - rx, scenePointerY - ry),
- lastPoint,
- ) >= LINE_CONFIRM_THRESHOLD
- ) {
- this.scene.mutateElement(
- multiElement,
- {
- points: [
- ...points,
- pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
- ],
- },
- { informMutation: false, isDragging: false },
- );
- } else {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
- // in this branch, we're inside the commit zone, and no uncommitted
- // point exists. Thus do nothing (don't add/remove points).
- }
- } else if (
- points.length > 2 &&
- lastCommittedPoint &&
- pointDistance(
- pointFrom(scenePointerX - rx, scenePointerY - ry),
- lastCommittedPoint,
- ) < LINE_CONFIRM_THRESHOLD
- ) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
- this.scene.mutateElement(
- multiElement,
- {
- points: points.slice(0, -1),
- },
- { informMutation: false, isDragging: false },
- );
- } else {
- const [gridX, gridY] = getGridPoint(
- scenePointerX,
- scenePointerY,
- event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
- ? null
- : this.getEffectiveGridSize(),
- );
- const [lastCommittedX, lastCommittedY] =
- multiElement?.lastCommittedPoint ?? [0, 0];
- let dxFromLastCommitted = gridX - rx - lastCommittedX;
- let dyFromLastCommitted = gridY - ry - lastCommittedY;
- if (shouldRotateWithDiscreteAngle(event)) {
- ({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
- getLockedLinearCursorAlignSize(
- // actual coordinate of the last committed point
- lastCommittedX + rx,
- lastCommittedY + ry,
- // cursor-grid coordinate
- gridX,
- gridY,
- ));
- }
- if (isPathALoop(points, this.state.zoom.value)) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
- }
- // update last uncommitted point
- this.scene.mutateElement(
- multiElement,
- {
- points: [
- ...points.slice(0, -1),
- pointFrom<LocalPoint>(
- lastCommittedX + dxFromLastCommitted,
- lastCommittedY + dyFromLastCommitted,
- ),
- ],
- },
- {
- isDragging: true,
- informMutation: false,
- },
- );
- // in this path, we're mutating multiElement to reflect
- // how it will be after adding pointer position as the next point
- // trigger update here so that new element canvas renders again to reflect this
- this.triggerRender(false);
- }
- return;
- }
- const hasDeselectedButton = Boolean(event.buttons);
- if (
- hasDeselectedButton ||
- (this.state.activeTool.type !== "selection" &&
- this.state.activeTool.type !== "text" &&
- this.state.activeTool.type !== "eraser")
- ) {
- return;
- }
- const elements = this.scene.getNonDeletedElements();
- const selectedElements = this.scene.getSelectedElements(this.state);
- if (
- selectedElements.length === 1 &&
- !isOverScrollBar &&
- !this.state.selectedLinearElement?.isEditing
- ) {
- // for linear elements, we'd like to prioritize point dragging over edge resizing
- // therefore, we update and check hovered point index first
- if (this.state.selectedLinearElement) {
- this.handleHoverSelectedLinearElement(
- this.state.selectedLinearElement,
- scenePointerX,
- scenePointerY,
- );
- }
- if (
- (!this.state.selectedLinearElement ||
- this.state.selectedLinearElement.hoverPointIndex === -1) &&
- this.state.openDialog?.name !== "elementLinkSelector" &&
- !(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
- ) {
- const elementWithTransformHandleType =
- getElementWithTransformHandleType(
- elements,
- this.state,
- scenePointerX,
- scenePointerY,
- this.state.zoom,
- event.pointerType,
- this.scene.getNonDeletedElementsMap(),
- this.device,
- );
- if (
- elementWithTransformHandleType &&
- elementWithTransformHandleType.transformHandleType
- ) {
- setCursor(
- this.interactiveCanvas,
- getCursorForResizingElement(elementWithTransformHandleType),
- );
- return;
- }
- }
- } else if (
- selectedElements.length > 1 &&
- !isOverScrollBar &&
- this.state.openDialog?.name !== "elementLinkSelector"
- ) {
- const transformHandleType = getTransformHandleTypeFromCoords(
- getCommonBounds(selectedElements),
- scenePointerX,
- scenePointerY,
- this.state.zoom,
- event.pointerType,
- this.device,
- );
- if (transformHandleType) {
- setCursor(
- this.interactiveCanvas,
- getCursorForResizingElement({
- transformHandleType,
- }),
- );
- return;
- }
- }
- const hitElementMightBeLocked = this.getElementAtPosition(
- scenePointerX,
- scenePointerY,
- {
- preferSelected: true,
- includeLockedElements: true,
- },
- );
- let hitElement: ExcalidrawElement | null = null;
- if (hitElementMightBeLocked && hitElementMightBeLocked.locked) {
- hitElement = null;
- } else {
- hitElement = hitElementMightBeLocked;
- }
- this.hitLinkElement = this.getElementLinkAtPosition(
- scenePointer,
- hitElementMightBeLocked,
- );
- if (isEraserActive(this.state)) {
- return;
- }
- if (
- this.hitLinkElement &&
- !this.state.selectedElementIds[this.hitLinkElement.id]
- ) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
- showHyperlinkTooltip(
- this.hitLinkElement,
- this.state,
- this.scene.getNonDeletedElementsMap(),
- );
- } else {
- hideHyperlinkToolip();
- if (
- hitElement &&
- (hitElement.link || isEmbeddableElement(hitElement)) &&
- this.state.selectedElementIds[hitElement.id] &&
- !this.state.contextMenu &&
- !this.state.showHyperlinkPopup
- ) {
- this.setState({ showHyperlinkPopup: "info" });
- } else if (this.state.activeTool.type === "text") {
- setCursor(
- this.interactiveCanvas,
- isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
- );
- } else if (this.state.viewModeEnabled) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
- } else if (this.state.openDialog?.name === "elementLinkSelector") {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
- } else if (isOverScrollBar) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
- } else if (
- // if using cmd/ctrl, we're not dragging
- !event[KEYS.CTRL_OR_CMD]
- ) {
- if (
- (hitElement ||
- this.isHittingCommonBoundingBoxOfSelectedElements(
- scenePointer,
- selectedElements,
- )) &&
- !hitElement?.locked
- ) {
- if (
- hitElement &&
- isIframeLikeElement(hitElement) &&
- this.isIframeLikeElementCenter(
- hitElement,
- event,
- scenePointerX,
- scenePointerY,
- )
- ) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
- this.setState({
- activeEmbeddable: { element: hitElement, state: "hover" },
- });
- } else if (
- !hitElement ||
- // Ebow arrows can only be moved when unconnected
- !isElbowArrow(hitElement) ||
- !(hitElement.startBinding || hitElement.endBinding)
- ) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
- if (this.state.activeEmbeddable?.state === "hover") {
- this.setState({ activeEmbeddable: null });
- }
- }
- }
- } else {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
- }
- if (this.state.selectedLinearElement) {
- this.handleHoverSelectedLinearElement(
- this.state.selectedLinearElement,
- scenePointerX,
- scenePointerY,
- );
- }
- }
- if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) {
- this.setState((prevState) => {
- return {
- hoveredElementIds: updateStable(
- prevState.hoveredElementIds,
- selectGroupsForSelectedElements(
- {
- editingGroupId: prevState.editingGroupId,
- selectedElementIds: { [hitElement!.id]: true },
- },
- this.scene.getNonDeletedElements(),
- prevState,
- this,
- ).selectedElementIds,
- ),
- };
- });
- } else if (
- this.state.openDialog?.name === "elementLinkSelector" &&
- !hitElement
- ) {
- this.setState((prevState) => ({
- hoveredElementIds: updateStable(prevState.hoveredElementIds, {}),
- }));
- }
- };
- private handleEraser = (
- event: PointerEvent,
- scenePointer: { x: number; y: number },
- ) => {
- const elementsToErase = this.eraserTrail.addPointToPath(
- scenePointer.x,
- scenePointer.y,
- event.altKey,
- );
- this.elementsPendingErasure = new Set(elementsToErase);
- this.triggerRender();
- };
- // set touch moving for mobile context menu
- private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
- invalidateContextMenu = true;
- };
- handleHoverSelectedLinearElement(
- linearElementEditor: LinearElementEditor,
- scenePointerX: number,
- scenePointerY: number,
- ) {
- const elementsMap = this.scene.getNonDeletedElementsMap();
- const element = LinearElementEditor.getElement(
- linearElementEditor.elementId,
- elementsMap,
- );
- if (!element) {
- return;
- }
- if (this.state.selectedLinearElement) {
- let hoverPointIndex = -1;
- let segmentMidPointHoveredCoords = null;
- if (
- hitElementItself({
- point: pointFrom(scenePointerX, scenePointerY),
- element,
- elementsMap,
- threshold: this.getElementHitThreshold(element),
- })
- ) {
- hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
- element,
- elementsMap,
- this.state.zoom,
- scenePointerX,
- scenePointerY,
- );
- segmentMidPointHoveredCoords =
- LinearElementEditor.getSegmentMidpointHitCoords(
- linearElementEditor,
- { x: scenePointerX, y: scenePointerY },
- this.state,
- this.scene.getNonDeletedElementsMap(),
- );
- const isHoveringAPointHandle = isElbowArrow(element)
- ? hoverPointIndex === 0 ||
- hoverPointIndex === element.points.length - 1
- : hoverPointIndex >= 0;
- if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
- } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
- if (
- // Ebow arrows can only be moved when unconnected
- !isElbowArrow(element) ||
- !(element.startBinding || element.endBinding)
- ) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
- }
- }
- } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
- if (
- // Ebow arrows can only be moved when unconnected
- !isElbowArrow(element) ||
- !(element.startBinding || element.endBinding)
- ) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
- }
- }
- if (
- this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex
- ) {
- this.setState({
- selectedLinearElement: {
- ...this.state.selectedLinearElement,
- hoverPointIndex,
- },
- });
- }
- if (
- !LinearElementEditor.arePointsEqual(
- this.state.selectedLinearElement.segmentMidPointHoveredCoords,
- segmentMidPointHoveredCoords,
- )
- ) {
- this.setState({
- selectedLinearElement: {
- ...this.state.selectedLinearElement,
- segmentMidPointHoveredCoords,
- },
- });
- }
- } else {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
- }
- }
- private handleCanvasPointerDown = (
- event: React.PointerEvent<HTMLElement>,
- ) => {
- const target = event.target as HTMLElement;
- // capture subsequent pointer events to the canvas
- // this makes other elements non-interactive until pointer up
- if (target.setPointerCapture) {
- target.setPointerCapture(event.pointerId);
- }
- this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
- this.maybeUnfollowRemoteUser();
- if (this.state.searchMatches) {
- this.setState((state) => {
- return {
- searchMatches: state.searchMatches && {
- focusedId: null,
- matches: state.searchMatches.matches.map((searchMatch) => ({
- ...searchMatch,
- focus: false,
- })),
- },
- };
- });
- this.updateEditorAtom(searchItemInFocusAtom, null);
- }
- if (editorJotaiStore.get(convertElementTypePopupAtom)) {
- this.updateEditorAtom(convertElementTypePopupAtom, null);
- }
- // since contextMenu options are potentially evaluated on each render,
- // and an contextMenu action may depend on selection state, we must
- // close the contextMenu before we update the selection on pointerDown
- // (e.g. resetting selection)
- if (this.state.contextMenu) {
- this.setState({ contextMenu: null });
- }
- if (this.state.snapLines) {
- this.setAppState({ snapLines: [] });
- }
- this.updateGestureOnPointerDown(event);
- // if dragging element is freedraw and another pointerdown event occurs
- // a second finger is on the screen
- // discard the freedraw element if it is very short because it is likely
- // just a spike, otherwise finalize the freedraw element when the second
- // finger is lifted
- if (
- event.pointerType === "touch" &&
- this.state.newElement &&
- this.state.newElement.type === "freedraw"
- ) {
- const element = this.state.newElement as ExcalidrawFreeDrawElement;
- this.updateScene({
- ...(element.points.length < 10
- ? {
- elements: this.scene
- .getElementsIncludingDeleted()
- .filter((el) => el.id !== element.id),
- }
- : {}),
- appState: {
- newElement: null,
- editingTextElement: null,
- startBoundElement: null,
- suggestedBindings: [],
- selectedElementIds: makeNextSelectedElementIds(
- Object.keys(this.state.selectedElementIds)
- .filter((key) => key !== element.id)
- .reduce((obj: { [id: string]: true }, key) => {
- obj[key] = this.state.selectedElementIds[key];
- return obj;
- }, {}),
- this.state,
- ),
- },
- captureUpdate:
- this.state.openDialog?.name === "elementLinkSelector"
- ? CaptureUpdateAction.EVENTUALLY
- : CaptureUpdateAction.NEVER,
- });
- return;
- }
- // remove any active selection when we start to interact with canvas
- // (mainly, we care about removing selection outside the component which
- // would prevent our copy handling otherwise)
- const selection = document.getSelection();
- if (selection?.anchorNode) {
- selection.removeAllRanges();
- }
- this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
- //fires only once, if pen is detected, penMode is enabled
- //the user can disable this by toggling the penMode button
- if (!this.state.penDetected && event.pointerType === "pen") {
- this.setState((prevState) => {
- return {
- penMode: true,
- penDetected: true,
- };
- });
- }
- if (
- !this.device.isTouchScreen &&
- ["pen", "touch"].includes(event.pointerType)
- ) {
- this.device = updateObject(this.device, { isTouchScreen: true });
- }
- if (isPanning) {
- return;
- }
- this.lastPointerDownEvent = event;
- // we must exit before we set `cursorButton` state and `savePointer`
- // else it will send pointer state & laser pointer events in collab when
- // panning
- if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
- return;
- }
- this.setState({
- lastPointerDownWith: event.pointerType,
- cursorButton: "down",
- });
- this.savePointer(event.clientX, event.clientY, "down");
- if (
- event.button === POINTER_BUTTON.ERASER &&
- this.state.activeTool.type !== TOOL_TYPE.eraser
- ) {
- this.setState(
- {
- activeTool: updateActiveTool(this.state, {
- type: TOOL_TYPE.eraser,
- lastActiveToolBeforeEraser: this.state.activeTool,
- }),
- },
- () => {
- this.handleCanvasPointerDown(event);
- const onPointerUp = () => {
- unsubPointerUp();
- unsubCleanup?.();
- if (isEraserActive(this.state)) {
- this.setState({
- activeTool: updateActiveTool(this.state, {
- ...(this.state.activeTool.lastActiveTool || {
- type: TOOL_TYPE.selection,
- }),
- lastActiveToolBeforeEraser: null,
- }),
- });
- }
- };
- const unsubPointerUp = addEventListener(
- window,
- EVENT.POINTER_UP,
- onPointerUp,
- {
- once: true,
- },
- );
- let unsubCleanup: UnsubscribeCallback | undefined;
- // subscribe inside rAF lest it'd be triggered on the same pointerdown
- // if we start erasing while coming from blurred document since
- // we cleanup pointer events on focus
- requestAnimationFrame(() => {
- unsubCleanup =
- this.missingPointerEventCleanupEmitter.once(onPointerUp);
- });
- },
- );
- return;
- }
- // only handle left mouse button or touch
- if (
- event.button !== POINTER_BUTTON.MAIN &&
- event.button !== POINTER_BUTTON.TOUCH &&
- event.button !== POINTER_BUTTON.ERASER
- ) {
- return;
- }
- // don't select while panning
- if (gesture.pointers.size > 1) {
- return;
- }
- // State for the duration of a pointer interaction, which starts with a
- // pointerDown event, ends with a pointerUp event (or another pointerDown)
- const pointerDownState = this.initialPointerDownState(event);
- this.setState({
- selectedElementsAreBeingDragged: false,
- });
- if (this.handleDraggingScrollBar(event, pointerDownState)) {
- return;
- }
- this.clearSelectionIfNotUsingSelection();
- this.updateBindingEnabledOnPointerMove(event);
- if (this.handleSelectionOnPointerDown(event, pointerDownState)) {
- return;
- }
- const allowOnPointerDown =
- !this.state.penMode ||
- event.pointerType !== "touch" ||
- this.state.activeTool.type === "selection" ||
- this.state.activeTool.type === "lasso" ||
- this.state.activeTool.type === "text" ||
- this.state.activeTool.type === "image";
- if (!allowOnPointerDown) {
- return;
- }
- if (this.state.activeTool.type === "lasso") {
- this.lassoTrail.startPath(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- event.shiftKey,
- );
- } else if (this.state.activeTool.type === "text") {
- this.handleTextOnPointerDown(event, pointerDownState);
- } else if (
- this.state.activeTool.type === "arrow" ||
- this.state.activeTool.type === "line"
- ) {
- this.handleLinearElementOnPointerDown(
- event,
- this.state.activeTool.type,
- pointerDownState,
- );
- } else if (this.state.activeTool.type === "freedraw") {
- this.handleFreeDrawElementOnPointerDown(
- event,
- this.state.activeTool.type,
- pointerDownState,
- );
- } else if (this.state.activeTool.type === "custom") {
- setCursorForShape(this.interactiveCanvas, this.state);
- } else if (
- this.state.activeTool.type === TOOL_TYPE.frame ||
- this.state.activeTool.type === TOOL_TYPE.magicframe
- ) {
- this.createFrameElementOnPointerDown(
- pointerDownState,
- this.state.activeTool.type,
- );
- } else if (this.state.activeTool.type === "laser") {
- this.laserTrails.startPath(
- pointerDownState.lastCoords.x,
- pointerDownState.lastCoords.y,
- );
- } else if (
- this.state.activeTool.type !== "eraser" &&
- this.state.activeTool.type !== "hand" &&
- this.state.activeTool.type !== "image"
- ) {
- this.createGenericElementOnPointerDown(
- this.state.activeTool.type,
- pointerDownState,
- );
- }
- this.props?.onPointerDown?.(this.state.activeTool, pointerDownState);
- this.onPointerDownEmitter.trigger(
- this.state.activeTool,
- pointerDownState,
- event,
- );
- if (this.state.activeTool.type === "eraser") {
- this.eraserTrail.startPath(
- pointerDownState.lastCoords.x,
- pointerDownState.lastCoords.y,
- );
- }
- const onPointerMove =
- this.onPointerMoveFromPointerDownHandler(pointerDownState);
- const onPointerUp =
- this.onPointerUpFromPointerDownHandler(pointerDownState);
- const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState);
- const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState);
- this.missingPointerEventCleanupEmitter.once((_event) =>
- onPointerUp(_event || event.nativeEvent),
- );
- if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
- window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
- window.addEventListener(EVENT.POINTER_UP, onPointerUp);
- window.addEventListener(EVENT.KEYDOWN, onKeyDown);
- window.addEventListener(EVENT.KEYUP, onKeyUp);
- pointerDownState.eventListeners.onMove = onPointerMove;
- pointerDownState.eventListeners.onUp = onPointerUp;
- pointerDownState.eventListeners.onKeyUp = onKeyUp;
- pointerDownState.eventListeners.onKeyDown = onKeyDown;
- }
- };
- private handleCanvasPointerUp = (
- event: React.PointerEvent<HTMLCanvasElement>,
- ) => {
- this.removePointer(event);
- this.lastPointerUpEvent = event;
- const scenePointer = viewportCoordsToSceneCoords(
- { clientX: event.clientX, clientY: event.clientY },
- this.state,
- );
- const clicklength =
- event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
- if (this.device.editor.isMobile && clicklength < 300) {
- const hitElement = this.getElementAtPosition(
- scenePointer.x,
- scenePointer.y,
- );
- if (
- isIframeLikeElement(hitElement) &&
- this.isIframeLikeElementCenter(
- hitElement,
- event,
- scenePointer.x,
- scenePointer.y,
- )
- ) {
- this.handleEmbeddableCenterClick(hitElement);
- return;
- }
- }
- if (this.device.isTouchScreen) {
- const hitElement = this.getElementAtPosition(
- scenePointer.x,
- scenePointer.y,
- {
- includeLockedElements: true,
- },
- );
- this.hitLinkElement = this.getElementLinkAtPosition(
- scenePointer,
- hitElement,
- );
- }
- if (
- this.hitLinkElement &&
- !this.state.selectedElementIds[this.hitLinkElement.id]
- ) {
- if (
- clicklength < 300 &&
- isIframeLikeElement(this.hitLinkElement) &&
- !isPointHittingLinkIcon(
- this.hitLinkElement,
- this.scene.getNonDeletedElementsMap(),
- this.state,
- pointFrom(scenePointer.x, scenePointer.y),
- )
- ) {
- this.handleEmbeddableCenterClick(this.hitLinkElement);
- } else {
- this.redirectToLink(event, this.device.isTouchScreen);
- }
- } else if (this.state.viewModeEnabled) {
- this.setState({
- activeEmbeddable: null,
- selectedElementIds: {},
- });
- }
- };
- private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
- event: React.PointerEvent<HTMLElement>,
- ): void => {
- // deal with opening context menu on touch devices
- if (event.pointerType === "touch") {
- invalidateContextMenu = false;
- if (touchTimeout) {
- // If there's already a touchTimeout, this means that there's another
- // touch down and we are doing another touch, so we shouldn't open the
- // context menu.
- invalidateContextMenu = true;
- } else {
- // open the context menu with the first touch's clientX and clientY
- // if the touch is not moving
- touchTimeout = window.setTimeout(() => {
- touchTimeout = 0;
- if (!invalidateContextMenu) {
- this.handleCanvasContextMenu(event);
- }
- }, TOUCH_CTX_MENU_TIMEOUT);
- }
- }
- };
- private resetContextMenuTimer = () => {
- clearTimeout(touchTimeout);
- touchTimeout = 0;
- invalidateContextMenu = false;
- };
- /**
- * pointerup may not fire in certian cases (user tabs away...), so in order
- * to properly cleanup pointerdown state, we need to fire any hanging
- * pointerup handlers manually
- */
- private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => {
- lastPointerUp?.();
- this.missingPointerEventCleanupEmitter.trigger(event).clear();
- };
- // Returns whether the event is a panning
- public handleCanvasPanUsingWheelOrSpaceDrag = (
- event: React.PointerEvent<HTMLElement> | MouseEvent,
- ): boolean => {
- if (
- !(
- gesture.pointers.size <= 1 &&
- (event.button === POINTER_BUTTON.WHEEL ||
- (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
- isHandToolActive(this.state) ||
- this.state.viewModeEnabled)
- )
- ) {
- return false;
- }
- isPanning = true;
- // due to event.preventDefault below, container wouldn't get focus
- // automatically
- this.focusContainer();
- // preventing defualt while text editing messes with cursor/focus
- if (!this.state.editingTextElement) {
- // necessary to prevent browser from scrolling the page if excalidraw
- // not full-page #4489
- //
- // as such, the above is broken when panning canvas while in wysiwyg
- event.preventDefault();
- }
- let nextPastePrevented = false;
- const isLinux =
- typeof window === undefined
- ? false
- : /Linux/.test(window.navigator.platform);
- setCursor(this.interactiveCanvas, CURSOR_TYPE.GRABBING);
- let { clientX: lastX, clientY: lastY } = event;
- const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => {
- const deltaX = lastX - event.clientX;
- const deltaY = lastY - event.clientY;
- lastX = event.clientX;
- lastY = event.clientY;
- /*
- * Prevent paste event if we move while middle clicking on Linux.
- * See issue #1383.
- */
- if (
- isLinux &&
- !nextPastePrevented &&
- (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)
- ) {
- nextPastePrevented = true;
- /* Prevent the next paste event */
- const preventNextPaste = (event: ClipboardEvent) => {
- document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
- event.stopPropagation();
- };
- /*
- * Reenable next paste in case of disabled middle click paste for
- * any reason:
- * - right click paste
- * - empty clipboard
- */
- const enableNextPaste = () => {
- setTimeout(() => {
- document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
- window.removeEventListener(EVENT.POINTER_UP, enableNextPaste);
- }, 100);
- };
- document.body.addEventListener(EVENT.PASTE, preventNextPaste);
- window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
- }
- this.translateCanvas({
- scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
- scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
- });
- });
- const teardown = withBatchedUpdates(
- (lastPointerUp = () => {
- lastPointerUp = null;
- isPanning = false;
- if (!isHoldingSpace) {
- if (this.state.viewModeEnabled) {
- setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
- } else {
- setCursorForShape(this.interactiveCanvas, this.state);
- }
- }
- this.setState({
- cursorButton: "up",
- });
- this.savePointer(event.clientX, event.clientY, "up");
- window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
- window.removeEventListener(EVENT.POINTER_UP, teardown);
- window.removeEventListener(EVENT.BLUR, teardown);
- onPointerMove.flush();
- }),
- );
- window.addEventListener(EVENT.BLUR, teardown);
- window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, {
- passive: true,
- });
- window.addEventListener(EVENT.POINTER_UP, teardown);
- return true;
- };
- private updateGestureOnPointerDown(
- event: React.PointerEvent<HTMLElement>,
- ): void {
- gesture.pointers.set(event.pointerId, {
- x: event.clientX,
- y: event.clientY,
- });
- if (gesture.pointers.size === 2) {
- gesture.lastCenter = getCenter(gesture.pointers);
- gesture.initialScale = this.state.zoom.value;
- gesture.initialDistance = getDistance(
- Array.from(gesture.pointers.values()),
- );
- }
- }
- private initialPointerDownState(
- event: React.PointerEvent<HTMLElement>,
- ): PointerDownState {
- const origin = viewportCoordsToSceneCoords(event, this.state);
- const selectedElements = this.scene.getSelectedElements(this.state);
- const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
- const isElbowArrowOnly = selectedElements.findIndex(isElbowArrow) === 0;
- return {
- origin,
- withCmdOrCtrl: event[KEYS.CTRL_OR_CMD],
- originInGrid: tupleToCoors(
- getGridPoint(
- origin.x,
- origin.y,
- event[KEYS.CTRL_OR_CMD] || isElbowArrowOnly
- ? null
- : this.getEffectiveGridSize(),
- ),
- ),
- scrollbars: isOverScrollBars(
- currentScrollBars,
- event.clientX - this.state.offsetLeft,
- event.clientY - this.state.offsetTop,
- ),
- // we need to duplicate because we'll be updating this state
- lastCoords: { ...origin },
- originalElements: this.scene
- .getNonDeletedElements()
- .reduce((acc, element) => {
- acc.set(element.id, deepCopyElement(element));
- return acc;
- }, new Map() as PointerDownState["originalElements"]),
- resize: {
- handleType: false,
- isResizing: false,
- offset: { x: 0, y: 0 },
- arrowDirection: "origin",
- center: { x: (maxX + minX) / 2, y: (maxY + minY) / 2 },
- },
- hit: {
- element: null,
- allHitElements: [],
- wasAddedToSelection: false,
- hasBeenDuplicated: false,
- hasHitCommonBoundingBoxOfSelectedElements:
- this.isHittingCommonBoundingBoxOfSelectedElements(
- origin,
- selectedElements,
- ),
- },
- drag: {
- hasOccurred: false,
- offset: null,
- origin: { ...origin },
- },
- eventListeners: {
- onMove: null,
- onUp: null,
- onKeyUp: null,
- onKeyDown: null,
- },
- boxSelection: {
- hasOccurred: false,
- },
- };
- }
- // Returns whether the event is a dragging a scrollbar
- private handleDraggingScrollBar(
- event: React.PointerEvent<HTMLElement>,
- pointerDownState: PointerDownState,
- ): boolean {
- if (
- !(pointerDownState.scrollbars.isOverEither && !this.state.multiElement)
- ) {
- return false;
- }
- isDraggingScrollBar = true;
- pointerDownState.lastCoords.x = event.clientX;
- pointerDownState.lastCoords.y = event.clientY;
- const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => {
- const target = event.target;
- if (!(target instanceof HTMLElement)) {
- return;
- }
- this.handlePointerMoveOverScrollbars(event, pointerDownState);
- });
- const onPointerUp = withBatchedUpdates(() => {
- lastPointerUp = null;
- isDraggingScrollBar = false;
- setCursorForShape(this.interactiveCanvas, this.state);
- this.setState({
- cursorButton: "up",
- });
- this.savePointer(event.clientX, event.clientY, "up");
- window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
- window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
- onPointerMove.flush();
- });
- lastPointerUp = onPointerUp;
- window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
- window.addEventListener(EVENT.POINTER_UP, onPointerUp);
- return true;
- }
- private clearSelectionIfNotUsingSelection = (): void => {
- if (
- this.state.activeTool.type !== "selection" &&
- this.state.activeTool.type !== "lasso"
- ) {
- this.setState({
- selectedElementIds: makeNextSelectedElementIds({}, this.state),
- selectedGroupIds: {},
- editingGroupId: null,
- activeEmbeddable: null,
- });
- }
- };
- /**
- * @returns whether the pointer event has been completely handled
- */
- private handleSelectionOnPointerDown = (
- event: React.PointerEvent<HTMLElement>,
- pointerDownState: PointerDownState,
- ): boolean => {
- if (this.state.activeTool.type === "selection") {
- const elements = this.scene.getNonDeletedElements();
- const elementsMap = this.scene.getNonDeletedElementsMap();
- const selectedElements = this.scene.getSelectedElements(this.state);
- if (
- selectedElements.length === 1 &&
- !this.state.selectedLinearElement?.isEditing &&
- !isElbowArrow(selectedElements[0]) &&
- !(
- this.state.selectedLinearElement &&
- this.state.selectedLinearElement.hoverPointIndex !== -1
- )
- ) {
- const elementWithTransformHandleType =
- getElementWithTransformHandleType(
- elements,
- this.state,
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- this.state.zoom,
- event.pointerType,
- this.scene.getNonDeletedElementsMap(),
- this.device,
- );
- if (elementWithTransformHandleType != null) {
- if (
- elementWithTransformHandleType.transformHandleType === "rotation"
- ) {
- this.setState({
- resizingElement: elementWithTransformHandleType.element,
- });
- pointerDownState.resize.handleType =
- elementWithTransformHandleType.transformHandleType;
- } else if (this.state.croppingElementId) {
- pointerDownState.resize.handleType =
- elementWithTransformHandleType.transformHandleType;
- } else {
- this.setState({
- resizingElement: elementWithTransformHandleType.element,
- });
- pointerDownState.resize.handleType =
- elementWithTransformHandleType.transformHandleType;
- }
- }
- } else if (selectedElements.length > 1) {
- pointerDownState.resize.handleType = getTransformHandleTypeFromCoords(
- getCommonBounds(selectedElements),
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- this.state.zoom,
- event.pointerType,
- this.device,
- );
- }
- if (pointerDownState.resize.handleType) {
- pointerDownState.resize.isResizing = true;
- pointerDownState.resize.offset = tupleToCoors(
- getResizeOffsetXY(
- pointerDownState.resize.handleType,
- selectedElements,
- elementsMap,
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- ),
- );
- if (
- selectedElements.length === 1 &&
- isLinearElement(selectedElements[0]) &&
- selectedElements[0].points.length === 2
- ) {
- pointerDownState.resize.arrowDirection = getResizeArrowDirection(
- pointerDownState.resize.handleType,
- selectedElements[0],
- );
- }
- } else {
- if (this.state.selectedLinearElement) {
- const linearElementEditor = this.state.selectedLinearElement;
- const ret = LinearElementEditor.handlePointerDown(
- event,
- this,
- this.store,
- pointerDownState.origin,
- linearElementEditor,
- this.scene,
- );
- if (ret.hitElement) {
- pointerDownState.hit.element = ret.hitElement;
- }
- if (ret.linearElementEditor) {
- this.setState({ selectedLinearElement: ret.linearElementEditor });
- }
- if (ret.didAddPoint) {
- return true;
- }
- }
- const allHitElements = this.getElementsAtPosition(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- {
- includeLockedElements: true,
- },
- );
- const unlockedHitElements = allHitElements.filter((e) => !e.locked);
- // Cannot set preferSelected in getElementAtPosition as we do in pointer move; consider:
- // A & B: both unlocked, A selected, B on top, A & B overlaps in some way
- // we want to select B when clicking on the overlapping area
- const hitElementMightBeLocked = this.getElementAtPosition(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- {
- allHitElements,
- },
- );
- if (
- !hitElementMightBeLocked ||
- hitElementMightBeLocked.id !== this.state.activeLockedId
- ) {
- this.setState({
- activeLockedId: null,
- });
- }
- if (
- hitElementMightBeLocked &&
- hitElementMightBeLocked.locked &&
- !unlockedHitElements.some(
- (el) => this.state.selectedElementIds[el.id],
- )
- ) {
- pointerDownState.hit.element = null;
- } else {
- // hitElement may already be set above, so check first
- pointerDownState.hit.element =
- pointerDownState.hit.element ??
- this.getElementAtPosition(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- );
- }
- this.hitLinkElement = this.getElementLinkAtPosition(
- pointerDownState.origin,
- hitElementMightBeLocked,
- );
- if (this.hitLinkElement) {
- return true;
- }
- if (
- this.state.croppingElementId &&
- pointerDownState.hit.element?.id !== this.state.croppingElementId
- ) {
- this.finishImageCropping();
- }
- if (pointerDownState.hit.element) {
- // Early return if pointer is hitting link icon
- const hitLinkElement = this.getElementLinkAtPosition(
- {
- x: pointerDownState.origin.x,
- y: pointerDownState.origin.y,
- },
- pointerDownState.hit.element,
- );
- if (hitLinkElement) {
- return false;
- }
- }
- // For overlapped elements one position may hit
- // multiple elements
- pointerDownState.hit.allHitElements = unlockedHitElements;
- const hitElement = pointerDownState.hit.element;
- const someHitElementIsSelected =
- pointerDownState.hit.allHitElements.some((element) =>
- this.isASelectedElement(element),
- );
- if (
- (hitElement === null || !someHitElementIsSelected) &&
- !event.shiftKey &&
- !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
- ) {
- this.clearSelection(hitElement);
- }
- if (this.state.selectedLinearElement?.isEditing) {
- this.setState({
- selectedElementIds: makeNextSelectedElementIds(
- {
- [this.state.selectedLinearElement.elementId]: true,
- },
- this.state,
- ),
- });
- // If we click on something
- } else if (hitElement != null) {
- // == deep selection ==
- // 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
- return false;
- }
- if (!this.state.selectedElementIds[hitElement.id]) {
- pointerDownState.hit.wasAddedToSelection = true;
- }
- this.setState((prevState) => ({
- ...editGroupForSelectedElement(prevState, hitElement),
- previousSelectedElementIds: this.state.selectedElementIds,
- }));
- // mark as not completely handled so as to allow dragging etc.
- return false;
- }
- // deselect if item is selected
- // if shift is not clicked, this will always return true
- // otherwise, it will trigger selection based on current
- // state of the box
- if (!this.state.selectedElementIds[hitElement.id]) {
- // if we are currently editing a group, exiting editing mode and deselect the group.
- if (
- this.state.editingGroupId &&
- !isElementInGroup(hitElement, this.state.editingGroupId)
- ) {
- this.setState({
- selectedElementIds: makeNextSelectedElementIds({}, this.state),
- selectedGroupIds: {},
- editingGroupId: null,
- activeEmbeddable: null,
- });
- }
- // Add hit element to selection. At this point if we're not holding
- // SHIFT the previously selected element(s) were deselected above
- // (make sure you use setState updater to use latest state)
- // With shift-selection, we want to make sure that frames and their containing
- // elements are not selected at the same time.
- if (
- !someHitElementIsSelected &&
- !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
- ) {
- this.setState((prevState) => {
- let nextSelectedElementIds: { [id: string]: true } = {
- ...prevState.selectedElementIds,
- [hitElement.id]: true,
- };
- const previouslySelectedElements: ExcalidrawElement[] = [];
- Object.keys(prevState.selectedElementIds).forEach((id) => {
- const element = this.scene.getElement(id);
- element && previouslySelectedElements.push(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];
- });
- }
- });
- }
- }
- // Finally, in shape selection mode, we'd like to
- // keep only one shape or group selected at a time.
- // This means, if the hitElement is a different shape or group
- // than the previously selected ones, we deselect the previous ones
- // and select the hitElement
- if (prevState.openDialog?.name === "elementLinkSelector") {
- if (
- !hitElement.groupIds.some(
- (gid) => prevState.selectedGroupIds[gid],
- )
- ) {
- nextSelectedElementIds = {
- [hitElement.id]: true,
- };
- }
- }
- return {
- ...selectGroupsForSelectedElements(
- {
- editingGroupId: prevState.editingGroupId,
- selectedElementIds: nextSelectedElementIds,
- },
- this.scene.getNonDeletedElements(),
- prevState,
- this,
- ),
- showHyperlinkPopup:
- hitElement.link || isEmbeddableElement(hitElement)
- ? "info"
- : false,
- };
- });
- pointerDownState.hit.wasAddedToSelection = true;
- }
- }
- }
- this.setState({
- previousSelectedElementIds: this.state.selectedElementIds,
- });
- }
- }
- return false;
- };
- private isASelectedElement(hitElement: ExcalidrawElement | null): boolean {
- return hitElement != null && this.state.selectedElementIds[hitElement.id];
- }
- private isHittingCommonBoundingBoxOfSelectedElements(
- point: Readonly<{ x: number; y: number }>,
- selectedElements: readonly ExcalidrawElement[],
- ): boolean {
- if (selectedElements.length < 2) {
- return false;
- }
- // How many pixels off the shape boundary we still consider a hit
- const threshold = Math.max(
- DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value,
- 1,
- );
- const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
- return (
- point.x > x1 - threshold &&
- point.x < x2 + threshold &&
- point.y > y1 - threshold &&
- point.y < y2 + threshold
- );
- }
- private handleTextOnPointerDown = (
- event: React.PointerEvent<HTMLElement>,
- pointerDownState: PointerDownState,
- ): void => {
- // if we're currently still editing text, clicking outside
- // should only finalize it, not create another (irrespective
- // of state.activeTool.locked)
- if (this.state.editingTextElement) {
- return;
- }
- let sceneX = pointerDownState.origin.x;
- let sceneY = pointerDownState.origin.y;
- const element = this.getElementAtPosition(sceneX, sceneY, {
- includeBoundTextElement: true,
- });
- // FIXME
- let container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
- if (hasBoundTextElement(element)) {
- container = element as ExcalidrawTextContainer;
- sceneX = element.x + element.width / 2;
- sceneY = element.y + element.height / 2;
- }
- this.startTextEditing({
- sceneX,
- sceneY,
- insertAtParentCenter: !event.altKey,
- container,
- autoEdit: false,
- });
- resetCursor(this.interactiveCanvas);
- if (!this.state.activeTool.locked) {
- this.setState({
- activeTool: updateActiveTool(this.state, { type: "selection" }),
- });
- }
- };
- private handleFreeDrawElementOnPointerDown = (
- event: React.PointerEvent<HTMLElement>,
- elementType: ExcalidrawFreeDrawElement["type"],
- pointerDownState: PointerDownState,
- ) => {
- // Begin a mark capture. This does not have to update state yet.
- const [gridX, gridY] = getGridPoint(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- null,
- );
- const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
- x: gridX,
- y: gridY,
- });
- const simulatePressure = event.pressure === 0.5;
- const element = newFreeDrawElement({
- type: elementType,
- x: gridX,
- y: gridY,
- strokeColor: this.state.currentItemStrokeColor,
- backgroundColor: this.state.currentItemBackgroundColor,
- fillStyle: this.state.currentItemFillStyle,
- strokeWidth: this.state.currentItemStrokeWidth,
- strokeStyle: this.state.currentItemStrokeStyle,
- roughness: this.state.currentItemRoughness,
- opacity: this.state.currentItemOpacity,
- roundness: null,
- simulatePressure,
- locked: false,
- frameId: topLayerFrame ? topLayerFrame.id : null,
- points: [pointFrom<LocalPoint>(0, 0)],
- pressures: simulatePressure ? [] : [event.pressure],
- });
- this.scene.insertElement(element);
- this.setState((prevState) => {
- const nextSelectedElementIds = {
- ...prevState.selectedElementIds,
- };
- delete nextSelectedElementIds[element.id];
- return {
- selectedElementIds: makeNextSelectedElementIds(
- nextSelectedElementIds,
- prevState,
- ),
- };
- });
- const boundElement = getHoveredElementForBinding(
- pointerDownState.origin,
- this.scene.getNonDeletedElements(),
- this.scene.getNonDeletedElementsMap(),
- this.state.zoom,
- );
- this.setState({
- newElement: element,
- startBoundElement: boundElement,
- suggestedBindings: [],
- });
- };
- public insertIframeElement = ({
- sceneX,
- sceneY,
- width,
- height,
- }: {
- sceneX: number;
- sceneY: number;
- width: number;
- height: number;
- }) => {
- const [gridX, gridY] = getGridPoint(
- sceneX,
- sceneY,
- this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
- ? null
- : this.getEffectiveGridSize(),
- );
- const element = newIframeElement({
- type: "iframe",
- x: gridX,
- y: gridY,
- strokeColor: "transparent",
- backgroundColor: "transparent",
- fillStyle: this.state.currentItemFillStyle,
- strokeWidth: this.state.currentItemStrokeWidth,
- strokeStyle: this.state.currentItemStrokeStyle,
- roughness: this.state.currentItemRoughness,
- roundness: this.getCurrentItemRoundness("iframe"),
- opacity: this.state.currentItemOpacity,
- locked: false,
- width,
- height,
- });
- this.scene.insertElement(element);
- return element;
- };
- //create rectangle element with youtube top left on nearest grid point width / hight 640/360
- public insertEmbeddableElement = ({
- sceneX,
- sceneY,
- link,
- }: {
- sceneX: number;
- sceneY: number;
- link: string;
- }) => {
- const [gridX, gridY] = getGridPoint(
- sceneX,
- sceneY,
- this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
- ? null
- : this.getEffectiveGridSize(),
- );
- const embedLink = getEmbedLink(link);
- if (!embedLink) {
- return;
- }
- if (embedLink.error instanceof URIError) {
- this.setToast({
- message: t("toast.unrecognizedLinkFormat"),
- closable: true,
- });
- }
- const element = newEmbeddableElement({
- type: "embeddable",
- x: gridX,
- y: gridY,
- strokeColor: "transparent",
- backgroundColor: "transparent",
- fillStyle: this.state.currentItemFillStyle,
- strokeWidth: this.state.currentItemStrokeWidth,
- strokeStyle: this.state.currentItemStrokeStyle,
- roughness: this.state.currentItemRoughness,
- roundness: this.getCurrentItemRoundness("embeddable"),
- opacity: this.state.currentItemOpacity,
- locked: false,
- width: embedLink.intrinsicSize.w,
- height: embedLink.intrinsicSize.h,
- link,
- });
- this.scene.insertElement(element);
- return element;
- };
- private createImageElement = async ({
- sceneX,
- sceneY,
- addToFrameUnderCursor = true,
- imageFile,
- }: {
- sceneX: number;
- sceneY: number;
- addToFrameUnderCursor?: boolean;
- imageFile: File;
- }) => {
- const [gridX, gridY] = getGridPoint(
- sceneX,
- sceneY,
- this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
- ? null
- : this.getEffectiveGridSize(),
- );
- const topLayerFrame = addToFrameUnderCursor
- ? this.getTopLayerFrameAtSceneCoords({
- x: gridX,
- y: gridY,
- })
- : null;
- const placeholderSize = 100 / this.state.zoom.value;
- const placeholderImageElement = newImageElement({
- type: "image",
- strokeColor: this.state.currentItemStrokeColor,
- backgroundColor: this.state.currentItemBackgroundColor,
- fillStyle: this.state.currentItemFillStyle,
- strokeWidth: this.state.currentItemStrokeWidth,
- strokeStyle: this.state.currentItemStrokeStyle,
- roughness: this.state.currentItemRoughness,
- roundness: null,
- opacity: this.state.currentItemOpacity,
- locked: false,
- frameId: topLayerFrame ? topLayerFrame.id : null,
- x: gridX - placeholderSize / 2,
- y: gridY - placeholderSize / 2,
- width: placeholderSize,
- height: placeholderSize,
- });
- const initializedImageElement = await this.insertImageElement(
- placeholderImageElement,
- imageFile,
- );
- return initializedImageElement;
- };
- private handleLinearElementOnPointerDown = (
- event: React.PointerEvent<HTMLElement>,
- elementType: ExcalidrawLinearElement["type"],
- pointerDownState: PointerDownState,
- ): void => {
- if (this.state.multiElement) {
- const { multiElement } = this.state;
- // finalize if completing a loop
- if (
- multiElement.type === "line" &&
- isPathALoop(multiElement.points, this.state.zoom.value)
- ) {
- this.scene.mutateElement(multiElement, {
- lastCommittedPoint:
- multiElement.points[multiElement.points.length - 1],
- });
- this.actionManager.executeAction(actionFinalize);
- return;
- }
- // Elbow arrows cannot be created by putting down points
- // only the start and end points can be defined
- if (isElbowArrow(multiElement) && multiElement.points.length > 1) {
- this.scene.mutateElement(multiElement, {
- lastCommittedPoint:
- multiElement.points[multiElement.points.length - 1],
- });
- this.actionManager.executeAction(actionFinalize);
- return;
- }
- const { x: rx, y: ry, lastCommittedPoint } = multiElement;
- // clicking inside commit zone → finalize arrow
- if (
- multiElement.points.length > 1 &&
- lastCommittedPoint &&
- pointDistance(
- pointFrom(
- pointerDownState.origin.x - rx,
- pointerDownState.origin.y - ry,
- ),
- lastCommittedPoint,
- ) < LINE_CONFIRM_THRESHOLD
- ) {
- this.actionManager.executeAction(actionFinalize);
- return;
- }
- this.setState((prevState) => ({
- selectedElementIds: makeNextSelectedElementIds(
- {
- ...prevState.selectedElementIds,
- [multiElement.id]: true,
- },
- prevState,
- ),
- }));
- // clicking outside commit zone → update reference for last committed
- // point
- this.scene.mutateElement(multiElement, {
- lastCommittedPoint: multiElement.points[multiElement.points.length - 1],
- });
- setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
- } else {
- const [gridX, gridY] = getGridPoint(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
- );
- const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
- x: gridX,
- y: gridY,
- });
- /* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
- If so, we want it to be null for start and "arrow" for end. If the linear item is not
- an arrow, we want it to be null for both. Otherwise, we want it to use the
- values from appState. */
- const { currentItemStartArrowhead, currentItemEndArrowhead } = this.state;
- const [startArrowhead, endArrowhead] =
- elementType === "arrow"
- ? [currentItemStartArrowhead, currentItemEndArrowhead]
- : [null, null];
- const element =
- elementType === "arrow"
- ? newArrowElement({
- type: elementType,
- x: gridX,
- y: gridY,
- strokeColor: this.state.currentItemStrokeColor,
- backgroundColor: this.state.currentItemBackgroundColor,
- fillStyle: this.state.currentItemFillStyle,
- strokeWidth: this.state.currentItemStrokeWidth,
- strokeStyle: this.state.currentItemStrokeStyle,
- roughness: this.state.currentItemRoughness,
- opacity: this.state.currentItemOpacity,
- roundness:
- this.state.currentItemArrowType === ARROW_TYPE.round
- ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
- : // note, roundness doesn't have any effect for elbow arrows,
- // but it's best to set it to null as well
- null,
- startArrowhead,
- endArrowhead,
- locked: false,
- frameId: topLayerFrame ? topLayerFrame.id : null,
- elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
- fixedSegments:
- this.state.currentItemArrowType === ARROW_TYPE.elbow
- ? []
- : null,
- })
- : newLinearElement({
- type: elementType,
- x: gridX,
- y: gridY,
- strokeColor: this.state.currentItemStrokeColor,
- backgroundColor: this.state.currentItemBackgroundColor,
- fillStyle: this.state.currentItemFillStyle,
- strokeWidth: this.state.currentItemStrokeWidth,
- strokeStyle: this.state.currentItemStrokeStyle,
- roughness: this.state.currentItemRoughness,
- opacity: this.state.currentItemOpacity,
- roundness:
- this.state.currentItemRoundness === "round"
- ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
- : null,
- locked: false,
- frameId: topLayerFrame ? topLayerFrame.id : null,
- });
- this.setState((prevState) => {
- const nextSelectedElementIds = {
- ...prevState.selectedElementIds,
- };
- delete nextSelectedElementIds[element.id];
- return {
- selectedElementIds: makeNextSelectedElementIds(
- nextSelectedElementIds,
- prevState,
- ),
- };
- });
- this.scene.mutateElement(element, {
- points: [...element.points, pointFrom<LocalPoint>(0, 0)],
- });
- const boundElement = getHoveredElementForBinding(
- pointerDownState.origin,
- this.scene.getNonDeletedElements(),
- this.scene.getNonDeletedElementsMap(),
- this.state.zoom,
- isElbowArrow(element),
- isElbowArrow(element),
- );
- this.scene.insertElement(element);
- this.setState({
- newElement: element,
- startBoundElement: boundElement,
- suggestedBindings: [],
- });
- }
- };
- private getCurrentItemRoundness(
- elementType:
- | "selection"
- | "rectangle"
- | "diamond"
- | "ellipse"
- | "iframe"
- | "embeddable",
- ) {
- return this.state.currentItemRoundness === "round"
- ? {
- type: isUsingAdaptiveRadius(elementType)
- ? ROUNDNESS.ADAPTIVE_RADIUS
- : ROUNDNESS.PROPORTIONAL_RADIUS,
- }
- : null;
- }
- private createGenericElementOnPointerDown = (
- elementType: ExcalidrawGenericElement["type"] | "embeddable",
- pointerDownState: PointerDownState,
- ): void => {
- const [gridX, gridY] = getGridPoint(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
- ? null
- : this.getEffectiveGridSize(),
- );
- const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
- x: gridX,
- y: gridY,
- });
- const baseElementAttributes = {
- x: gridX,
- y: gridY,
- strokeColor: this.state.currentItemStrokeColor,
- backgroundColor: this.state.currentItemBackgroundColor,
- fillStyle: this.state.currentItemFillStyle,
- strokeWidth: this.state.currentItemStrokeWidth,
- strokeStyle: this.state.currentItemStrokeStyle,
- roughness: this.state.currentItemRoughness,
- opacity: this.state.currentItemOpacity,
- roundness: this.getCurrentItemRoundness(elementType),
- locked: false,
- frameId: topLayerFrame ? topLayerFrame.id : null,
- } as const;
- let element;
- if (elementType === "embeddable") {
- element = newEmbeddableElement({
- type: "embeddable",
- ...baseElementAttributes,
- });
- } else {
- element = newElement({
- type: elementType,
- ...baseElementAttributes,
- });
- }
- if (element.type === "selection") {
- this.setState({
- selectionElement: element,
- });
- } else {
- this.scene.insertElement(element);
- this.setState({
- multiElement: null,
- newElement: element,
- });
- }
- };
- private createFrameElementOnPointerDown = (
- pointerDownState: PointerDownState,
- type: Extract<ToolType, "frame" | "magicframe">,
- ): void => {
- const [gridX, gridY] = getGridPoint(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
- ? null
- : this.getEffectiveGridSize(),
- );
- const constructorOpts = {
- x: gridX,
- y: gridY,
- opacity: this.state.currentItemOpacity,
- locked: false,
- ...FRAME_STYLE,
- } as const;
- const frame =
- type === TOOL_TYPE.magicframe
- ? newMagicFrameElement(constructorOpts)
- : newFrameElement(constructorOpts);
- this.scene.insertElement(frame);
- this.setState({
- multiElement: null,
- newElement: frame,
- });
- };
- private maybeCacheReferenceSnapPoints(
- event: KeyboardModifiersObject,
- selectedElements: ExcalidrawElement[],
- recomputeAnyways: boolean = false,
- ) {
- if (
- isSnappingEnabled({
- event,
- app: this,
- selectedElements,
- }) &&
- (recomputeAnyways || !SnapCache.getReferenceSnapPoints())
- ) {
- SnapCache.setReferenceSnapPoints(
- getReferenceSnapPoints(
- this.scene.getNonDeletedElements(),
- selectedElements,
- this.state,
- this.scene.getNonDeletedElementsMap(),
- ),
- );
- }
- }
- private maybeCacheVisibleGaps(
- event: KeyboardModifiersObject,
- selectedElements: ExcalidrawElement[],
- recomputeAnyways: boolean = false,
- ) {
- if (
- isSnappingEnabled({
- event,
- app: this,
- selectedElements,
- }) &&
- (recomputeAnyways || !SnapCache.getVisibleGaps())
- ) {
- SnapCache.setVisibleGaps(
- getVisibleGaps(
- this.scene.getNonDeletedElements(),
- selectedElements,
- this.state,
- this.scene.getNonDeletedElementsMap(),
- ),
- );
- }
- }
- private onKeyDownFromPointerDownHandler(
- pointerDownState: PointerDownState,
- ): (event: KeyboardEvent) => void {
- return withBatchedUpdates((event: KeyboardEvent) => {
- if (this.maybeHandleResize(pointerDownState, event)) {
- return;
- }
- this.maybeDragNewGenericElement(pointerDownState, event);
- });
- }
- private onKeyUpFromPointerDownHandler(
- pointerDownState: PointerDownState,
- ): (event: KeyboardEvent) => void {
- return withBatchedUpdates((event: KeyboardEvent) => {
- // Prevents focus from escaping excalidraw tab
- event.key === KEYS.ALT && event.preventDefault();
- if (this.maybeHandleResize(pointerDownState, event)) {
- return;
- }
- this.maybeDragNewGenericElement(pointerDownState, event);
- });
- }
- private onPointerMoveFromPointerDownHandler(
- pointerDownState: PointerDownState,
- ) {
- return withBatchedUpdatesThrottled((event: PointerEvent) => {
- if (this.state.openDialog?.name === "elementLinkSelector") {
- return;
- }
- const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
- if (this.state.activeLockedId) {
- this.setState({
- activeLockedId: null,
- });
- }
- if (
- this.state.selectedLinearElement &&
- this.state.selectedLinearElement.elbowed &&
- this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index
- ) {
- const [gridX, gridY] = getGridPoint(
- pointerCoords.x,
- pointerCoords.y,
- event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
- );
- let index =
- this.state.selectedLinearElement.pointerDownState.segmentMidpoint
- .index;
- if (index < 0) {
- const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
- {
- ...this.state.selectedLinearElement,
- segmentMidPointHoveredCoords: null,
- },
- { x: gridX, y: gridY },
- this.state,
- this.scene.getNonDeletedElementsMap(),
- );
- index = nextCoords
- ? LinearElementEditor.getSegmentMidPointIndex(
- this.state.selectedLinearElement,
- this.state,
- nextCoords,
- this.scene.getNonDeletedElementsMap(),
- )
- : -1;
- }
- const ret = LinearElementEditor.moveFixedSegment(
- this.state.selectedLinearElement,
- index,
- gridX,
- gridY,
- this.scene,
- );
- this.setState({
- selectedLinearElement: {
- ...this.state.selectedLinearElement,
- segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
- pointerDownState: ret.pointerDownState,
- },
- });
- return;
- }
- const lastPointerCoords =
- this.lastPointerMoveCoords ?? pointerDownState.origin;
- this.lastPointerMoveCoords = pointerCoords;
- // We need to initialize dragOffsetXY only after we've updated
- // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
- // event handler should hopefully ensure we're already working with
- // the updated state.
- if (pointerDownState.drag.offset === null) {
- pointerDownState.drag.offset = tupleToCoors(
- getDragOffsetXY(
- this.scene.getSelectedElements(this.state),
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- ),
- );
- }
- const target = event.target;
- if (!(target instanceof HTMLElement)) {
- return;
- }
- if (this.handlePointerMoveOverScrollbars(event, pointerDownState)) {
- return;
- }
- if (isEraserActive(this.state)) {
- this.handleEraser(event, pointerCoords);
- return;
- }
- if (this.state.activeTool.type === "laser") {
- this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
- }
- const [gridX, gridY] = getGridPoint(
- pointerCoords.x,
- pointerCoords.y,
- event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
- );
- // for arrows/lines, 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 pointermove)
- if (
- !pointerDownState.drag.hasOccurred &&
- (this.state.activeTool.type === "arrow" ||
- this.state.activeTool.type === "line")
- ) {
- if (
- pointDistance(
- pointFrom(pointerCoords.x, pointerCoords.y),
- pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
- ) *
- this.state.zoom.value <
- MINIMUM_ARROW_SIZE
- ) {
- return;
- }
- }
- if (pointerDownState.resize.isResizing) {
- pointerDownState.lastCoords.x = pointerCoords.x;
- pointerDownState.lastCoords.y = pointerCoords.y;
- if (this.maybeHandleCrop(pointerDownState, event)) {
- return true;
- }
- if (this.maybeHandleResize(pointerDownState, event)) {
- return true;
- }
- }
- const elementsMap = this.scene.getNonDeletedElementsMap();
- if (this.state.selectedLinearElement) {
- const linearElementEditor = this.state.selectedLinearElement;
- if (
- LinearElementEditor.shouldAddMidpoint(
- this.state.selectedLinearElement,
- pointerCoords,
- this.state,
- elementsMap,
- )
- ) {
- const ret = LinearElementEditor.addMidpoint(
- this.state.selectedLinearElement,
- pointerCoords,
- this,
- !event[KEYS.CTRL_OR_CMD],
- this.scene,
- );
- if (!ret) {
- return;
- }
- // Since we are reading from previous state which is not possible with
- // automatic batching in React 18 hence using flush sync to synchronously
- // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
- flushSync(() => {
- if (this.state.selectedLinearElement) {
- this.setState({
- selectedLinearElement: {
- ...this.state.selectedLinearElement,
- pointerDownState: ret.pointerDownState,
- selectedPointsIndices: ret.selectedPointsIndices,
- segmentMidPointHoveredCoords: null,
- },
- });
- }
- });
- return;
- } else if (
- linearElementEditor.pointerDownState.segmentMidpoint.value !== null &&
- !linearElementEditor.pointerDownState.segmentMidpoint.added
- ) {
- return;
- }
- const newState = LinearElementEditor.handlePointDragging(
- event,
- this,
- pointerCoords.x,
- pointerCoords.y,
- linearElementEditor,
- );
- if (newState) {
- pointerDownState.lastCoords.x = pointerCoords.x;
- pointerDownState.lastCoords.y = pointerCoords.y;
- pointerDownState.drag.hasOccurred = true;
- this.setState(newState);
- return;
- }
- }
- const hasHitASelectedElement = pointerDownState.hit.allHitElements.some(
- (element) => this.isASelectedElement(element),
- );
- const isSelectingPointsInLineEditor =
- this.state.selectedLinearElement?.isEditing &&
- event.shiftKey &&
- this.state.selectedLinearElement.elementId ===
- pointerDownState.hit.element?.id;
- if (
- (hasHitASelectedElement ||
- pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
- !isSelectingPointsInLineEditor &&
- this.state.activeTool.type !== "lasso"
- ) {
- const selectedElements = this.scene.getSelectedElements(this.state);
- if (selectedElements.every((element) => element.locked)) {
- return;
- }
- const selectedElementsHasAFrame = selectedElements.find((e) =>
- isFrameLikeElement(e),
- );
- const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords);
- const frameToHighlight =
- topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null;
- // Only update the state if there is a difference
- if (this.state.frameToHighlight !== frameToHighlight) {
- flushSync(() => {
- this.setState({ frameToHighlight });
- });
- }
- // Marking that click was used for dragging to check
- // if elements should be deselected on pointerup
- pointerDownState.drag.hasOccurred = true;
- // 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
- if (
- selectedElements.length > 0 &&
- !pointerDownState.withCmdOrCtrl &&
- !this.state.editingTextElement &&
- this.state.activeEmbeddable?.state !== "active"
- ) {
- const dragOffset = {
- x: pointerCoords.x - pointerDownState.drag.origin.x,
- y: pointerCoords.y - pointerDownState.drag.origin.y,
- };
- const originalElements = [
- ...pointerDownState.originalElements.values(),
- ];
- // We only drag in one direction if shift is pressed
- const lockDirection = event.shiftKey;
- if (lockDirection) {
- const distanceX = Math.abs(dragOffset.x);
- const distanceY = Math.abs(dragOffset.y);
- const lockX = lockDirection && distanceX < distanceY;
- const lockY = lockDirection && distanceX > distanceY;
- if (lockX) {
- dragOffset.x = 0;
- }
- if (lockY) {
- dragOffset.y = 0;
- }
- }
- // #region move crop region
- if (this.state.croppingElementId) {
- const croppingElement = this.scene
- .getNonDeletedElementsMap()
- .get(this.state.croppingElementId);
- if (
- croppingElement &&
- isImageElement(croppingElement) &&
- croppingElement.crop !== null &&
- pointerDownState.hit.element === croppingElement
- ) {
- const crop = croppingElement.crop;
- const image =
- isInitializedImageElement(croppingElement) &&
- this.imageCache.get(croppingElement.fileId)?.image;
- if (image && !(image instanceof Promise)) {
- const instantDragOffset = vectorScale(
- vector(
- pointerCoords.x - lastPointerCoords.x,
- pointerCoords.y - lastPointerCoords.y,
- ),
- Math.max(this.state.zoom.value, 2),
- );
- const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
- croppingElement,
- elementsMap,
- );
- const topLeft = vectorFromPoint(
- pointRotateRads(
- pointFrom(x1, y1),
- pointFrom(cx, cy),
- croppingElement.angle,
- ),
- );
- const topRight = vectorFromPoint(
- pointRotateRads(
- pointFrom(x2, y1),
- pointFrom(cx, cy),
- croppingElement.angle,
- ),
- );
- const bottomLeft = vectorFromPoint(
- pointRotateRads(
- pointFrom(x1, y2),
- pointFrom(cx, cy),
- croppingElement.angle,
- ),
- );
- const topEdge = vectorNormalize(
- vectorSubtract(topRight, topLeft),
- );
- const leftEdge = vectorNormalize(
- vectorSubtract(bottomLeft, topLeft),
- );
- // project instantDrafOffset onto leftEdge and topEdge to decompose
- const offsetVector = vector(
- vectorDot(instantDragOffset, topEdge),
- vectorDot(instantDragOffset, leftEdge),
- );
- const nextCrop = {
- ...crop,
- x: clamp(
- crop.x -
- offsetVector[0] * Math.sign(croppingElement.scale[0]),
- 0,
- image.naturalWidth - crop.width,
- ),
- y: clamp(
- crop.y -
- offsetVector[1] * Math.sign(croppingElement.scale[1]),
- 0,
- image.naturalHeight - crop.height,
- ),
- };
- this.scene.mutateElement(croppingElement, {
- crop: nextCrop,
- });
- return;
- }
- }
- }
- // Snap cache *must* be synchronously popuplated before initial drag,
- // otherwise the first drag even will not snap, causing a jump before
- // it snaps to its position if previously snapped already.
- this.maybeCacheVisibleGaps(event, selectedElements);
- this.maybeCacheReferenceSnapPoints(event, selectedElements);
- const { snapOffset, snapLines } = snapDraggedElements(
- originalElements,
- dragOffset,
- this,
- event,
- this.scene.getNonDeletedElementsMap(),
- );
- this.setState({ snapLines });
- // when we're editing the name of a frame, we want the user to be
- // able to select and interact with the text input
- if (!this.state.editingFrame) {
- dragSelectedElements(
- pointerDownState,
- selectedElements,
- dragOffset,
- this.scene,
- snapOffset,
- event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
- );
- }
- this.setState({
- selectedElementsAreBeingDragged: true,
- // element is being dragged and selectionElement that was created on pointer down
- // should be removed
- selectionElement: null,
- });
- if (
- selectedElements.length !== 1 ||
- !isElbowArrow(selectedElements[0])
- ) {
- this.setState({
- suggestedBindings: getSuggestedBindingsForArrows(
- selectedElements,
- this.scene.getNonDeletedElementsMap(),
- this.state.zoom,
- ),
- });
- }
- // We duplicate the selected element if alt is pressed on pointer move
- if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
- // Move the currently selected elements to the top of the z index stack, and
- // put the duplicates where the selected elements used to be.
- // (the origin point where the dragging started)
- pointerDownState.hit.hasBeenDuplicated = true;
- const elements = this.scene.getElementsIncludingDeleted();
- const hitElement = pointerDownState.hit.element;
- const selectedElements = this.scene.getSelectedElements({
- selectedElementIds: this.state.selectedElementIds,
- includeBoundTextElement: true,
- includeElementsInFrames: true,
- });
- if (
- hitElement &&
- // hit element may not end up being selected
- // if we're alt-dragging a common bounding box
- // over the hit element
- pointerDownState.hit.wasAddedToSelection &&
- !selectedElements.find((el) => el.id === hitElement.id)
- ) {
- selectedElements.push(hitElement);
- }
- const idsOfElementsToDuplicate = new Map(
- selectedElements.map((el) => [el.id, el]),
- );
- const {
- duplicatedElements,
- duplicateElementsMap,
- elementsWithDuplicates,
- origIdToDuplicateId,
- } = duplicateElements({
- type: "in-place",
- elements,
- appState: this.state,
- randomizeSeed: true,
- idsOfElementsToDuplicate,
- overrides: ({ duplicateElement, origElement }) => {
- return {
- // reset to the original element's frameId (unless we've
- // duplicated alongside a frame in which case we need to
- // keep the duplicate frame's id) so that the element
- // frame membership is refreshed on pointerup
- // NOTE this is a hacky solution and should be done
- // differently
- frameId: duplicateElement.frameId ?? origElement.frameId,
- seed: randomInteger(),
- };
- },
- });
- duplicatedElements.forEach((element) => {
- pointerDownState.originalElements.set(
- element.id,
- deepCopyElement(element),
- );
- });
- const mappedClonedElements = elementsWithDuplicates.map((el) => {
- if (idsOfElementsToDuplicate.has(el.id)) {
- const origEl = pointerDownState.originalElements.get(el.id);
- if (origEl) {
- return newElementWith(el, {
- x: origEl.x,
- y: origEl.y,
- });
- }
- }
- return el;
- });
- const mappedNewSceneElements = this.props.onDuplicate?.(
- mappedClonedElements,
- elements,
- );
- const elementsWithIndices = syncMovedIndices(
- mappedNewSceneElements || mappedClonedElements,
- arrayToMap(duplicatedElements),
- );
- // we need to update synchronously so as to keep pointerDownState,
- // appState, and scene elements in sync
- flushSync(() => {
- // swap hit element with the duplicated one
- if (pointerDownState.hit.element) {
- const cloneId = origIdToDuplicateId.get(
- pointerDownState.hit.element.id,
- );
- const clonedElement =
- cloneId && duplicateElementsMap.get(cloneId);
- pointerDownState.hit.element = clonedElement || null;
- }
- // swap hit elements with the duplicated ones
- pointerDownState.hit.allHitElements =
- pointerDownState.hit.allHitElements.reduce(
- (
- acc: typeof pointerDownState.hit.allHitElements,
- origHitElement,
- ) => {
- const cloneId = origIdToDuplicateId.get(origHitElement.id);
- const clonedElement =
- cloneId && duplicateElementsMap.get(cloneId);
- if (clonedElement) {
- acc.push(clonedElement);
- }
- return acc;
- },
- [],
- );
- // update drag origin to the position at which we started
- // the duplication so that the drag offset is correct
- pointerDownState.drag.origin = viewportCoordsToSceneCoords(
- event,
- this.state,
- );
- // switch selected elements to the duplicated ones
- this.setState((prevState) => ({
- ...getSelectionStateForElements(
- duplicatedElements,
- this.scene.getNonDeletedElements(),
- prevState,
- ),
- }));
- this.scene.replaceAllElements(elementsWithIndices);
- this.maybeCacheVisibleGaps(event, selectedElements, true);
- this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
- });
- }
- return;
- }
- }
- if (this.state.selectionElement) {
- pointerDownState.lastCoords.x = pointerCoords.x;
- pointerDownState.lastCoords.y = pointerCoords.y;
- if (event.altKey) {
- this.setActiveTool(
- { type: "lasso", fromSelection: true },
- event.shiftKey,
- );
- this.lassoTrail.startPath(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- event.shiftKey,
- );
- this.setAppState({
- selectionElement: null,
- });
- return;
- }
- this.maybeDragNewGenericElement(pointerDownState, event);
- } else if (this.state.activeTool.type === "lasso") {
- if (!event.altKey && this.state.activeTool.fromSelection) {
- this.setActiveTool({ type: "selection" });
- this.createGenericElementOnPointerDown("selection", pointerDownState);
- pointerDownState.lastCoords.x = pointerCoords.x;
- pointerDownState.lastCoords.y = pointerCoords.y;
- this.maybeDragNewGenericElement(pointerDownState, event);
- this.lassoTrail.endPath();
- } else {
- this.lassoTrail.addPointToPath(
- pointerCoords.x,
- pointerCoords.y,
- event.shiftKey,
- );
- }
- } else {
- // It is very important to read this.state within each move event,
- // otherwise we would read a stale one!
- const newElement = this.state.newElement;
- if (!newElement) {
- return;
- }
- if (newElement.type === "freedraw") {
- const points = newElement.points;
- const dx = pointerCoords.x - newElement.x;
- const dy = pointerCoords.y - newElement.y;
- const lastPoint = points.length > 0 && points[points.length - 1];
- const discardPoint =
- lastPoint && lastPoint[0] === dx && lastPoint[1] === dy;
- if (!discardPoint) {
- const pressures = newElement.simulatePressure
- ? newElement.pressures
- : [...newElement.pressures, event.pressure];
- this.scene.mutateElement(
- newElement,
- {
- points: [...points, pointFrom<LocalPoint>(dx, dy)],
- pressures,
- },
- {
- informMutation: false,
- isDragging: false,
- },
- );
- this.setState({
- newElement,
- });
- }
- } else if (isLinearElement(newElement)) {
- pointerDownState.drag.hasOccurred = true;
- const points = newElement.points;
- let dx = gridX - newElement.x;
- let dy = gridY - newElement.y;
- if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
- ({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
- newElement.x,
- newElement.y,
- pointerCoords.x,
- pointerCoords.y,
- ));
- }
- if (points.length === 1) {
- this.scene.mutateElement(
- newElement,
- {
- points: [...points, pointFrom<LocalPoint>(dx, dy)],
- },
- { informMutation: false, isDragging: false },
- );
- } else if (
- points.length === 2 ||
- (points.length > 1 && isElbowArrow(newElement))
- ) {
- this.scene.mutateElement(
- newElement,
- {
- points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
- },
- { isDragging: true, informMutation: false },
- );
- }
- this.setState({
- newElement,
- });
- if (isBindingElement(newElement, false)) {
- // When creating a linear element by dragging
- this.setState({
- suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords(
- newElement,
- [pointerCoords],
- this.scene,
- this.state.zoom,
- this.state.startBoundElement,
- ),
- });
- }
- } else {
- pointerDownState.lastCoords.x = pointerCoords.x;
- pointerDownState.lastCoords.y = pointerCoords.y;
- this.maybeDragNewGenericElement(pointerDownState, event, false);
- }
- }
- if (this.state.activeTool.type === "selection") {
- pointerDownState.boxSelection.hasOccurred = true;
- const elements = this.scene.getNonDeletedElements();
- // box-select line editor points
- if (this.state.selectedLinearElement?.isEditing) {
- LinearElementEditor.handleBoxSelection(
- event,
- this.state,
- this.setState.bind(this),
- this.scene.getNonDeletedElementsMap(),
- );
- // regular box-select
- } else {
- let shouldReuseSelection = true;
- if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
- if (
- pointerDownState.withCmdOrCtrl &&
- pointerDownState.hit.element
- ) {
- this.setState((prevState) =>
- selectGroupsForSelectedElements(
- {
- ...prevState,
- selectedElementIds: {
- [pointerDownState.hit.element!.id]: true,
- },
- },
- this.scene.getNonDeletedElements(),
- prevState,
- this,
- ),
- );
- } else {
- shouldReuseSelection = false;
- }
- }
- const elementsWithinSelection = this.state.selectionElement
- ? getElementsWithinSelection(
- elements,
- this.state.selectionElement,
- this.scene.getNonDeletedElementsMap(),
- false,
- )
- : [];
- this.setState((prevState) => {
- const nextSelectedElementIds = {
- ...(shouldReuseSelection && prevState.selectedElementIds),
- ...elementsWithinSelection.reduce(
- (acc: Record<ExcalidrawElement["id"], true>, element) => {
- acc[element.id] = true;
- return acc;
- },
- {},
- ),
- };
- if (pointerDownState.hit.element) {
- // if using ctrl/cmd, select the hitElement only if we
- // haven't box-selected anything else
- if (!elementsWithinSelection.length) {
- nextSelectedElementIds[pointerDownState.hit.element.id] = true;
- } else {
- delete nextSelectedElementIds[pointerDownState.hit.element.id];
- }
- }
- prevState = !shouldReuseSelection
- ? { ...prevState, selectedGroupIds: {}, editingGroupId: null }
- : prevState;
- return {
- ...selectGroupsForSelectedElements(
- {
- editingGroupId: prevState.editingGroupId,
- selectedElementIds: nextSelectedElementIds,
- },
- this.scene.getNonDeletedElements(),
- prevState,
- this,
- ),
- // select linear element only when we haven't box-selected anything else
- selectedLinearElement:
- elementsWithinSelection.length === 1 &&
- isLinearElement(elementsWithinSelection[0])
- ? new LinearElementEditor(
- elementsWithinSelection[0],
- this.scene.getNonDeletedElementsMap(),
- )
- : null,
- showHyperlinkPopup:
- elementsWithinSelection.length === 1 &&
- (elementsWithinSelection[0].link ||
- isEmbeddableElement(elementsWithinSelection[0]))
- ? "info"
- : false,
- };
- });
- }
- }
- });
- }
- // Returns whether the pointer move happened over either scrollbar
- private handlePointerMoveOverScrollbars(
- event: PointerEvent,
- pointerDownState: PointerDownState,
- ): boolean {
- if (pointerDownState.scrollbars.isOverHorizontal) {
- const x = event.clientX;
- const dx = x - pointerDownState.lastCoords.x;
- this.translateCanvas({
- scrollX:
- this.state.scrollX -
- (dx * (currentScrollBars.horizontal?.deltaMultiplier || 1)) /
- this.state.zoom.value,
- });
- pointerDownState.lastCoords.x = x;
- return true;
- }
- if (pointerDownState.scrollbars.isOverVertical) {
- const y = event.clientY;
- const dy = y - pointerDownState.lastCoords.y;
- this.translateCanvas({
- scrollY:
- this.state.scrollY -
- (dy * (currentScrollBars.vertical?.deltaMultiplier || 1)) /
- this.state.zoom.value,
- });
- pointerDownState.lastCoords.y = y;
- return true;
- }
- return false;
- }
- private onPointerUpFromPointerDownHandler(
- pointerDownState: PointerDownState,
- ): (event: PointerEvent) => void {
- return withBatchedUpdates((childEvent: PointerEvent) => {
- this.removePointer(childEvent);
- if (pointerDownState.eventListeners.onMove) {
- pointerDownState.eventListeners.onMove.flush();
- }
- const {
- newElement,
- resizingElement,
- croppingElementId,
- multiElement,
- activeTool,
- isResizing,
- isRotating,
- isCropping,
- } = this.state;
- this.setState((prevState) => ({
- isResizing: false,
- isRotating: false,
- isCropping: false,
- resizingElement: null,
- selectionElement: null,
- frameToHighlight: null,
- elementsToHighlight: null,
- cursorButton: "up",
- snapLines: updateStable(prevState.snapLines, []),
- originSnapOffset: null,
- }));
- // just in case, tool changes mid drag, always clean up
- this.lassoTrail.endPath();
- this.lastPointerMoveCoords = null;
- SnapCache.setReferenceSnapPoints(null);
- SnapCache.setVisibleGaps(null);
- this.savePointer(childEvent.clientX, childEvent.clientY, "up");
- // if current elements are still selected
- // and the pointer is just over a locked element
- // do not allow activeLockedId to be set
- const hitElements = pointerDownState.hit.allHitElements;
- const sceneCoords = viewportCoordsToSceneCoords(
- { clientX: childEvent.clientX, clientY: childEvent.clientY },
- this.state,
- );
- if (
- this.state.activeTool.type === "selection" &&
- !pointerDownState.boxSelection.hasOccurred &&
- !pointerDownState.resize.isResizing &&
- !hitElements.some((el) => this.state.selectedElementIds[el.id])
- ) {
- const hitLockedElement = this.getElementAtPosition(
- sceneCoords.x,
- sceneCoords.y,
- {
- includeLockedElements: true,
- },
- );
- this.store.scheduleCapture();
- if (hitLockedElement?.locked) {
- this.setState({
- activeLockedId:
- hitLockedElement.groupIds.length > 0
- ? hitLockedElement.groupIds.at(-1) || ""
- : hitLockedElement.id,
- });
- } else {
- this.setState({
- activeLockedId: null,
- });
- }
- } else {
- this.setState({
- activeLockedId: null,
- });
- }
- this.setState({
- selectedElementsAreBeingDragged: false,
- });
- const elementsMap = this.scene.getNonDeletedElementsMap();
- if (
- pointerDownState.drag.hasOccurred &&
- pointerDownState.hit?.element?.id
- ) {
- const element = elementsMap.get(pointerDownState.hit.element.id);
- if (isBindableElement(element)) {
- // Renormalize elbow arrows when they are changed via indirect move
- element.boundElements
- ?.filter((e) => e.type === "arrow")
- .map((e) => elementsMap.get(e.id))
- .filter((e) => isElbowArrow(e))
- .forEach((e) => {
- !!e && this.scene.mutateElement(e, {});
- });
- }
- }
- // Handle end of dragging a point of a linear element, might close a loop
- // and sets binding element
- if (this.state.selectedLinearElement?.isEditing) {
- if (
- !pointerDownState.boxSelection.hasOccurred &&
- pointerDownState.hit?.element?.id !==
- this.state.selectedLinearElement.elementId
- ) {
- this.actionManager.executeAction(actionFinalize);
- } else {
- const editingLinearElement = LinearElementEditor.handlePointerUp(
- childEvent,
- this.state.selectedLinearElement,
- this.state,
- this.scene,
- );
- if (editingLinearElement !== this.state.selectedLinearElement) {
- this.setState({
- selectedLinearElement: editingLinearElement,
- suggestedBindings: [],
- });
- }
- }
- } else if (this.state.selectedLinearElement) {
- // Normalize elbow arrow points, remove close parallel segments
- if (this.state.selectedLinearElement.elbowed) {
- const element = LinearElementEditor.getElement(
- this.state.selectedLinearElement.elementId,
- this.scene.getNonDeletedElementsMap(),
- );
- if (element) {
- this.scene.mutateElement(
- element as ExcalidrawElbowArrowElement,
- {},
- );
- }
- }
- if (
- pointerDownState.hit?.element?.id !==
- this.state.selectedLinearElement.elementId
- ) {
- const selectedELements = this.scene.getSelectedElements(this.state);
- // set selectedLinearElement to null if there is more than one element selected since we don't want to show linear element handles
- if (selectedELements.length > 1) {
- this.setState({ selectedLinearElement: null });
- }
- } else if (this.state.selectedLinearElement.isDragging) {
- this.actionManager.executeAction(actionFinalize, "ui", {
- event: childEvent,
- sceneCoords,
- });
- }
- }
- this.missingPointerEventCleanupEmitter.clear();
- window.removeEventListener(
- EVENT.POINTER_MOVE,
- pointerDownState.eventListeners.onMove!,
- );
- window.removeEventListener(
- EVENT.POINTER_UP,
- pointerDownState.eventListeners.onUp!,
- );
- window.removeEventListener(
- EVENT.KEYDOWN,
- pointerDownState.eventListeners.onKeyDown!,
- );
- window.removeEventListener(
- EVENT.KEYUP,
- pointerDownState.eventListeners.onKeyUp!,
- );
- this.props?.onPointerUp?.(activeTool, pointerDownState);
- this.onPointerUpEmitter.trigger(
- this.state.activeTool,
- pointerDownState,
- childEvent,
- );
- if (newElement?.type === "freedraw") {
- const pointerCoords = viewportCoordsToSceneCoords(
- childEvent,
- this.state,
- );
- const points = newElement.points;
- let dx = pointerCoords.x - newElement.x;
- let dy = pointerCoords.y - newElement.y;
- // Allows dots to avoid being flagged as infinitely small
- if (dx === points[0][0] && dy === points[0][1]) {
- dy += 0.0001;
- dx += 0.0001;
- }
- const pressures = newElement.simulatePressure
- ? []
- : [...newElement.pressures, childEvent.pressure];
- this.scene.mutateElement(newElement, {
- points: [...points, pointFrom<LocalPoint>(dx, dy)],
- pressures,
- lastCommittedPoint: pointFrom<LocalPoint>(dx, dy),
- });
- this.actionManager.executeAction(actionFinalize);
- return;
- }
- if (isLinearElement(newElement)) {
- if (newElement!.points.length > 1) {
- this.store.scheduleCapture();
- }
- const pointerCoords = viewportCoordsToSceneCoords(
- childEvent,
- this.state,
- );
- const dragDistance =
- pointDistance(
- pointFrom(pointerCoords.x, pointerCoords.y),
- pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
- ) * this.state.zoom.value;
- if (
- (!pointerDownState.drag.hasOccurred ||
- dragDistance < MINIMUM_ARROW_SIZE) &&
- newElement &&
- !multiElement
- ) {
- if (this.device.isTouchScreen) {
- const FIXED_DELTA_X = Math.min(
- (this.state.width * 0.7) / this.state.zoom.value,
- 100,
- );
- this.scene.mutateElement(
- newElement,
- {
- x: newElement.x - FIXED_DELTA_X / 2,
- points: [
- pointFrom<LocalPoint>(0, 0),
- pointFrom<LocalPoint>(FIXED_DELTA_X, 0),
- ],
- },
- { informMutation: false, isDragging: false },
- );
- this.actionManager.executeAction(actionFinalize);
- } else {
- const dx = pointerCoords.x - newElement.x;
- const dy = pointerCoords.y - newElement.y;
- this.scene.mutateElement(
- newElement,
- {
- points: [...newElement.points, pointFrom<LocalPoint>(dx, dy)],
- },
- { informMutation: false, isDragging: false },
- );
- this.setState({
- multiElement: newElement,
- newElement,
- });
- }
- } else if (pointerDownState.drag.hasOccurred && !multiElement) {
- if (
- isBindingEnabled(this.state) &&
- isBindingElement(newElement, false)
- ) {
- this.actionManager.executeAction(actionFinalize, "ui", {
- event: childEvent,
- sceneCoords,
- });
- }
- this.setState({ suggestedBindings: [], startBoundElement: null });
- if (!activeTool.locked) {
- resetCursor(this.interactiveCanvas);
- this.setState((prevState) => ({
- newElement: null,
- activeTool: updateActiveTool(this.state, {
- type: "selection",
- }),
- selectedElementIds: makeNextSelectedElementIds(
- {
- ...prevState.selectedElementIds,
- [newElement.id]: true,
- },
- prevState,
- ),
- selectedLinearElement: new LinearElementEditor(
- newElement,
- this.scene.getNonDeletedElementsMap(),
- ),
- }));
- } else {
- this.setState((prevState) => ({
- newElement: null,
- }));
- }
- // so that the scene gets rendered again to display the newly drawn linear as well
- this.scene.triggerUpdate();
- }
- return;
- }
- if (isTextElement(newElement)) {
- const minWidth = getMinTextElementWidth(
- getFontString({
- fontSize: newElement.fontSize,
- fontFamily: newElement.fontFamily,
- }),
- newElement.lineHeight,
- );
- if (newElement.width < minWidth) {
- this.scene.mutateElement(newElement, {
- autoResize: true,
- });
- }
- this.resetCursor();
- this.handleTextWysiwyg(newElement, {
- isExistingElement: true,
- });
- }
- if (
- activeTool.type !== "selection" &&
- newElement &&
- isInvisiblySmallElement(newElement)
- ) {
- // remove invisible element which was added in onPointerDown
- // update the store snapshot, so that invisible elements are not captured by the store
- this.updateScene({
- elements: this.scene
- .getElementsIncludingDeleted()
- .filter((el) => el.id !== newElement.id),
- appState: {
- newElement: null,
- },
- captureUpdate: CaptureUpdateAction.NEVER,
- });
- return;
- }
- if (isFrameLikeElement(newElement)) {
- const elementsInsideFrame = getElementsInNewFrame(
- this.scene.getElementsIncludingDeleted(),
- newElement,
- this.scene.getNonDeletedElementsMap(),
- );
- this.scene.replaceAllElements(
- addElementsToFrame(
- this.scene.getElementsMapIncludingDeleted(),
- elementsInsideFrame,
- newElement,
- this.state,
- ),
- );
- }
- if (newElement) {
- this.scene.mutateElement(
- newElement,
- getNormalizedDimensions(newElement),
- {
- informMutation: false,
- isDragging: false,
- },
- );
- // the above does not guarantee the scene to be rendered again, hence the trigger below
- this.scene.triggerUpdate();
- }
- if (pointerDownState.drag.hasOccurred) {
- const sceneCoords = viewportCoordsToSceneCoords(childEvent, this.state);
- // when editing the points of a linear element, we check if the
- // linear element still is in the frame afterwards
- // if not, the linear element will be removed from its frame (if any)
- if (
- this.state.selectedLinearElement &&
- this.state.selectedLinearElement.isDragging
- ) {
- const linearElement = this.scene.getElement(
- this.state.selectedLinearElement.elementId,
- );
- if (linearElement?.frameId) {
- const frame = getContainingFrame(linearElement, elementsMap);
- if (frame && linearElement) {
- if (
- !elementOverlapsWithFrame(
- linearElement,
- frame,
- this.scene.getNonDeletedElementsMap(),
- )
- ) {
- // remove the linear element from all groups
- // before removing it from the frame as well
- this.scene.mutateElement(linearElement, {
- groupIds: [],
- });
- removeElementsFromFrame(
- [linearElement],
- this.scene.getNonDeletedElementsMap(),
- );
- this.scene.triggerUpdate();
- }
- }
- }
- } else {
- // update the relationships between selected elements and frames
- const topLayerFrame = this.getTopLayerFrameAtSceneCoords(sceneCoords);
- const selectedElements = this.scene.getSelectedElements(this.state);
- let nextElements = this.scene.getElementsMapIncludingDeleted();
- const updateGroupIdsAfterEditingGroup = (
- elements: ExcalidrawElement[],
- ) => {
- if (elements.length > 0) {
- for (const element of elements) {
- const index = element.groupIds.indexOf(
- this.state.editingGroupId!,
- );
- this.scene.mutateElement(
- element,
- {
- groupIds: element.groupIds.slice(0, index),
- },
- { informMutation: false, isDragging: false },
- );
- }
- nextElements.forEach((element) => {
- if (
- element.groupIds.length &&
- getElementsInGroup(
- nextElements,
- element.groupIds[element.groupIds.length - 1],
- ).length < 2
- ) {
- this.scene.mutateElement(
- element,
- {
- groupIds: [],
- },
- { informMutation: false, isDragging: false },
- );
- }
- });
- this.setState({
- editingGroupId: null,
- });
- }
- };
- if (
- topLayerFrame &&
- !this.state.selectedElementIds[topLayerFrame.id]
- ) {
- const elementsToAdd = selectedElements.filter(
- (element) =>
- element.frameId !== topLayerFrame.id &&
- isElementInFrame(element, nextElements, this.state),
- );
- if (this.state.editingGroupId) {
- updateGroupIdsAfterEditingGroup(elementsToAdd);
- }
- nextElements = addElementsToFrame(
- nextElements,
- elementsToAdd,
- topLayerFrame,
- this.state,
- );
- } else if (!topLayerFrame) {
- if (this.state.editingGroupId) {
- const elementsToRemove = selectedElements.filter(
- (element) =>
- element.frameId &&
- !isElementInFrame(element, nextElements, this.state),
- );
- updateGroupIdsAfterEditingGroup(elementsToRemove);
- }
- }
- nextElements = updateFrameMembershipOfSelectedElements(
- nextElements,
- this.state,
- this,
- );
- this.scene.replaceAllElements(nextElements);
- }
- }
- if (resizingElement) {
- this.store.scheduleCapture();
- }
- if (resizingElement && isInvisiblySmallElement(resizingElement)) {
- // update the store snapshot, so that invisible elements are not captured by the store
- this.updateScene({
- elements: this.scene
- .getElementsIncludingDeleted()
- .filter((el) => el.id !== resizingElement.id),
- captureUpdate: CaptureUpdateAction.NEVER,
- });
- }
- // handle frame membership for resizing frames and/or selected elements
- if (pointerDownState.resize.isResizing) {
- let nextElements = updateFrameMembershipOfSelectedElements(
- this.scene.getElementsIncludingDeleted(),
- this.state,
- this,
- );
- const selectedFrames = this.scene
- .getSelectedElements(this.state)
- .filter((element): element is ExcalidrawFrameLikeElement =>
- isFrameLikeElement(element),
- );
- for (const frame of selectedFrames) {
- nextElements = replaceAllElementsInFrame(
- nextElements,
- getElementsInResizingFrame(
- this.scene.getElementsIncludingDeleted(),
- frame,
- this.state,
- elementsMap,
- ),
- frame,
- this,
- );
- }
- this.scene.replaceAllElements(nextElements);
- }
- // Code below handles selection when element(s) weren't
- // drag or added to selection on pointer down phase.
- const hitElement = pointerDownState.hit.element;
- if (
- this.state.selectedLinearElement?.elementId !== hitElement?.id &&
- isLinearElement(hitElement)
- ) {
- const selectedElements = this.scene.getSelectedElements(this.state);
- // set selectedLinearElement when no other element selected except
- // the one we've hit
- if (selectedElements.length === 1) {
- this.setState({
- selectedLinearElement: new LinearElementEditor(
- hitElement,
- this.scene.getNonDeletedElementsMap(),
- ),
- });
- }
- }
- // click outside the cropping region to exit
- if (
- // not in the cropping mode at all
- !croppingElementId ||
- // in the cropping mode
- (croppingElementId &&
- // not cropping and no hit element
- ((!hitElement && !isCropping) ||
- // hitting something else
- (hitElement && hitElement.id !== croppingElementId)))
- ) {
- this.finishImageCropping();
- }
- const pointerStart = this.lastPointerDownEvent;
- const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
- if (isEraserActive(this.state) && pointerStart && pointerEnd) {
- this.eraserTrail.endPath();
- const draggedDistance = pointDistance(
- pointFrom(pointerStart.clientX, pointerStart.clientY),
- pointFrom(pointerEnd.clientX, pointerEnd.clientY),
- );
- if (draggedDistance === 0) {
- const scenePointer = viewportCoordsToSceneCoords(
- {
- clientX: pointerEnd.clientX,
- clientY: pointerEnd.clientY,
- },
- this.state,
- );
- const hitElements = this.getElementsAtPosition(
- scenePointer.x,
- scenePointer.y,
- );
- hitElements.forEach((hitElement) =>
- this.elementsPendingErasure.add(hitElement.id),
- );
- }
- this.eraseElements();
- return;
- } else if (this.elementsPendingErasure.size) {
- this.restoreReadyToEraseElements();
- }
- if (
- hitElement &&
- !pointerDownState.drag.hasOccurred &&
- !pointerDownState.hit.wasAddedToSelection &&
- // if we're editing a line, pointerup shouldn't switch selection if
- // box selected
- (!this.state.selectedLinearElement?.isEditing ||
- !pointerDownState.boxSelection.hasOccurred) &&
- // hitElement can be set when alt + ctrl to toggle lasso and we will
- // just respect the selected elements from lasso instead
- this.state.activeTool.type !== "lasso"
- ) {
- // when inside line editor, shift selects points instead
- if (
- childEvent.shiftKey &&
- !this.state.selectedLinearElement?.isEditing
- ) {
- if (this.state.selectedElementIds[hitElement.id]) {
- if (isSelectedViaGroup(this.state, hitElement)) {
- this.setState((_prevState) => {
- const nextSelectedElementIds = {
- ..._prevState.selectedElementIds,
- };
- // We want to unselect all groups hitElement is part of
- // as well as all elements that are part of the groups
- // hitElement is part of
- for (const groupedElement of hitElement.groupIds.flatMap(
- (groupId) =>
- getElementsInGroup(
- this.scene.getNonDeletedElements(),
- groupId,
- ),
- )) {
- delete nextSelectedElementIds[groupedElement.id];
- }
- return {
- selectedGroupIds: {
- ..._prevState.selectedElementIds,
- ...hitElement.groupIds
- .map((gId) => ({ [gId]: false }))
- .reduce((prev, acc) => ({ ...prev, ...acc }), {}),
- },
- selectedElementIds: makeNextSelectedElementIds(
- nextSelectedElementIds,
- _prevState,
- ),
- };
- });
- // if not dragging a linear element point (outside editor)
- } else if (!this.state.selectedLinearElement?.isDragging) {
- // remove element from selection while
- // keeping prev elements selected
- this.setState((prevState) => {
- const newSelectedElementIds = {
- ...prevState.selectedElementIds,
- };
- delete newSelectedElementIds[hitElement!.id];
- const newSelectedElements = getSelectedElements(
- this.scene.getNonDeletedElements(),
- { selectedElementIds: newSelectedElementIds },
- );
- return {
- ...selectGroupsForSelectedElements(
- {
- editingGroupId: prevState.editingGroupId,
- selectedElementIds: newSelectedElementIds,
- },
- this.scene.getNonDeletedElements(),
- prevState,
- this,
- ),
- // set selectedLinearElement only if thats the only element selected
- selectedLinearElement:
- newSelectedElements.length === 1 &&
- isLinearElement(newSelectedElements[0])
- ? new LinearElementEditor(
- newSelectedElements[0],
- this.scene.getNonDeletedElementsMap(),
- )
- : prevState.selectedLinearElement,
- };
- });
- }
- } else if (
- hitElement.frameId &&
- this.state.selectedElementIds[hitElement.frameId]
- ) {
- // when hitElement is part of a selected frame, deselect the frame
- // to avoid frame and containing elements selected simultaneously
- this.setState((prevState) => {
- const nextSelectedElementIds: {
- [id: string]: true;
- } = {
- ...prevState.selectedElementIds,
- [hitElement.id]: true,
- };
- // deselect the frame
- delete nextSelectedElementIds[hitElement.frameId!];
- // deselect groups containing the frame
- (this.scene.getElement(hitElement.frameId!)?.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,
- };
- });
- } else {
- // add element to selection while keeping prev elements selected
- this.setState((_prevState) => ({
- selectedElementIds: makeNextSelectedElementIds(
- {
- ..._prevState.selectedElementIds,
- [hitElement!.id]: true,
- },
- _prevState,
- ),
- }));
- }
- } else {
- this.setState((prevState) => ({
- ...selectGroupsForSelectedElements(
- {
- editingGroupId: prevState.editingGroupId,
- selectedElementIds: { [hitElement.id]: true },
- },
- this.scene.getNonDeletedElements(),
- prevState,
- this,
- ),
- selectedLinearElement:
- isLinearElement(hitElement) &&
- // Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1.
- // Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized
- prevState.selectedLinearElement?.elementId !== hitElement.id
- ? new LinearElementEditor(
- hitElement,
- this.scene.getNonDeletedElementsMap(),
- )
- : prevState.selectedLinearElement,
- }));
- }
- }
- if (
- // do not clear selection if lasso is active
- this.state.activeTool.type !== "lasso" &&
- // not elbow midpoint dragged
- !(hitElement && isElbowArrow(hitElement)) &&
- // not dragged
- !pointerDownState.drag.hasOccurred &&
- // not resized
- !this.state.isResizing &&
- // only hitting the bounding box of the previous hit element
- ((hitElement &&
- hitElementBoundingBoxOnly(
- {
- point: pointFrom(
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- ),
- element: hitElement,
- elementsMap,
- threshold: this.getElementHitThreshold(hitElement),
- frameNameBound: isFrameLikeElement(hitElement)
- ? this.frameNameBoundsCache.get(hitElement)
- : null,
- },
- elementsMap,
- )) ||
- (!hitElement &&
- pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
- ) {
- if (this.state.selectedLinearElement?.isEditing) {
- // Exit editing mode but keep the element selected
- this.actionManager.executeAction(actionToggleLinearEditor);
- } else {
- // Deselect selected elements
- this.setState({
- selectedElementIds: makeNextSelectedElementIds({}, this.state),
- selectedGroupIds: {},
- editingGroupId: null,
- activeEmbeddable: null,
- });
- }
- // reset cursor
- setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
- return;
- }
- if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) {
- this.setState((prevState) => ({
- selectedElementIds: makeNextSelectedElementIds(
- {
- ...prevState.selectedElementIds,
- [newElement.id]: true,
- },
- prevState,
- ),
- showHyperlinkPopup:
- isEmbeddableElement(newElement) && !newElement.link
- ? "editor"
- : prevState.showHyperlinkPopup,
- }));
- }
- if (
- activeTool.type !== "selection" ||
- isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) ||
- !isShallowEqual(
- this.state.previousSelectedElementIds,
- this.state.selectedElementIds,
- )
- ) {
- this.store.scheduleCapture();
- }
- if (
- (pointerDownState.drag.hasOccurred &&
- !this.state.selectedLinearElement) ||
- isResizing ||
- isRotating ||
- isCropping
- ) {
- // We only allow binding via linear elements, specifically via dragging
- // the endpoints ("start" or "end").
- const linearElements = this.scene
- .getSelectedElements(this.state)
- .filter(isLinearElement);
- bindOrUnbindLinearElements(
- linearElements,
- isBindingEnabled(this.state),
- this.state.selectedLinearElement?.selectedPointsIndices ?? [],
- this.scene,
- this.state.zoom,
- );
- }
- if (activeTool.type === "laser") {
- this.laserTrails.endPath();
- return;
- }
- if (
- !activeTool.locked &&
- activeTool.type !== "freedraw" &&
- (activeTool.type !== "lasso" ||
- // if lasso is turned on but from selection => reset to selection
- (activeTool.type === "lasso" && activeTool.fromSelection))
- ) {
- resetCursor(this.interactiveCanvas);
- this.setState({
- newElement: null,
- suggestedBindings: [],
- activeTool: updateActiveTool(this.state, { type: "selection" }),
- });
- } else {
- this.setState({
- newElement: null,
- suggestedBindings: [],
- });
- }
- if (
- hitElement &&
- this.lastPointerUpEvent &&
- this.lastPointerDownEvent &&
- this.lastPointerUpEvent.timeStamp -
- this.lastPointerDownEvent.timeStamp <
- 300 &&
- gesture.pointers.size <= 1 &&
- isIframeLikeElement(hitElement) &&
- this.isIframeLikeElementCenter(
- hitElement,
- this.lastPointerUpEvent,
- pointerDownState.origin.x,
- pointerDownState.origin.y,
- )
- ) {
- this.handleEmbeddableCenterClick(hitElement);
- }
- });
- }
- private restoreReadyToEraseElements = () => {
- this.elementsPendingErasure = new Set();
- this.triggerRender();
- };
- private eraseElements = () => {
- let didChange = false;
- const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
- if (
- this.elementsPendingErasure.has(ele.id) ||
- (ele.frameId && this.elementsPendingErasure.has(ele.frameId)) ||
- (isBoundToContainer(ele) &&
- this.elementsPendingErasure.has(ele.containerId))
- ) {
- didChange = true;
- return newElementWith(ele, { isDeleted: true });
- }
- return ele;
- });
- this.elementsPendingErasure = new Set();
- if (didChange) {
- this.store.scheduleCapture();
- this.scene.replaceAllElements(elements);
- }
- };
- private initializeImage = async (
- placeholderImageElement: ExcalidrawImageElement,
- imageFile: File,
- ) => {
- // at this point this should be guaranteed image file, but we do this check
- // to satisfy TS down the line
- if (!isSupportedImageFile(imageFile)) {
- throw new Error(t("errors.unsupportedFileType"));
- }
- const mimeType = imageFile.type;
- setCursor(this.interactiveCanvas, "wait");
- if (mimeType === MIME_TYPES.svg) {
- try {
- imageFile = SVGStringToFile(
- normalizeSVG(await imageFile.text()),
- imageFile.name,
- );
- } catch (error: any) {
- console.warn(error);
- throw new Error(t("errors.svgImageInsertError"));
- }
- }
- // generate image id (by default the file digest) before any
- // resizing/compression takes place to keep it more portable
- const fileId = await ((this.props.generateIdForFile?.(
- imageFile,
- ) as Promise<FileId>) || generateIdFromFile(imageFile));
- if (!fileId) {
- console.warn(
- "Couldn't generate file id or the supplied `generateIdForFile` didn't resolve to one.",
- );
- throw new Error(t("errors.imageInsertError"));
- }
- const existingFileData = this.files[fileId];
- if (!existingFileData?.dataURL) {
- try {
- imageFile = await resizeImageFile(imageFile, {
- maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
- });
- } catch (error: any) {
- console.error(
- "Error trying to resizing image file on insertion",
- error,
- );
- }
- if (imageFile.size > MAX_ALLOWED_FILE_BYTES) {
- throw new Error(
- t("errors.fileTooBig", {
- maxSize: `${Math.trunc(MAX_ALLOWED_FILE_BYTES / 1024 / 1024)}MB`,
- }),
- );
- }
- }
- const dataURL =
- this.files[fileId]?.dataURL || (await getDataURL(imageFile));
- return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
- async (resolve, reject) => {
- try {
- let initializedImageElement = this.getLatestInitializedImageElement(
- placeholderImageElement,
- fileId,
- );
- this.addMissingFiles([
- {
- mimeType,
- id: fileId,
- dataURL,
- created: Date.now(),
- lastRetrieved: Date.now(),
- },
- ]);
- if (!this.imageCache.get(fileId)) {
- this.addNewImagesToImageCache();
- const { erroredFiles } = await this.updateImageCache([
- initializedImageElement,
- ]);
- if (erroredFiles.size) {
- throw new Error("Image cache update resulted with an error.");
- }
- }
- const imageHTML = await this.imageCache.get(fileId)?.image;
- if (
- imageHTML &&
- this.state.newElement?.id !== initializedImageElement.id
- ) {
- initializedImageElement = this.getLatestInitializedImageElement(
- placeholderImageElement,
- fileId,
- );
- const naturalDimensions = this.getImageNaturalDimensions(
- initializedImageElement,
- imageHTML,
- );
- // no need to create a new instance anymore, just assign the natural dimensions
- Object.assign(initializedImageElement, naturalDimensions);
- }
- resolve(initializedImageElement);
- } catch (error: any) {
- console.error(error);
- reject(new Error(t("errors.imageInsertError")));
- }
- },
- );
- };
- /**
- * use during async image initialization,
- * when the placeholder image could have been modified in the meantime,
- * and when you don't want to loose those modifications
- */
- private getLatestInitializedImageElement = (
- imagePlaceholder: ExcalidrawImageElement,
- fileId: FileId,
- ) => {
- const latestImageElement =
- this.scene.getElement(imagePlaceholder.id) ?? imagePlaceholder;
- return newElementWith(
- latestImageElement as InitializedExcalidrawImageElement,
- {
- fileId,
- },
- );
- };
- /**
- * inserts image into elements array and rerenders
- */
- private insertImageElement = async (
- placeholderImageElement: ExcalidrawImageElement,
- imageFile: File,
- ) => {
- // we should be handling all cases upstream, but in case we forget to handle
- // a future case, let's throw here
- if (!this.isToolSupported("image")) {
- this.setState({ errorMessage: t("errors.imageToolNotSupported") });
- return;
- }
- this.scene.insertElement(placeholderImageElement);
- try {
- const initializedImageElement = await this.initializeImage(
- placeholderImageElement,
- imageFile,
- );
- const nextElements = this.scene
- .getElementsIncludingDeleted()
- .map((element) => {
- if (element.id === initializedImageElement.id) {
- return initializedImageElement;
- }
- return element;
- });
- this.updateScene({
- captureUpdate: CaptureUpdateAction.IMMEDIATELY,
- elements: nextElements,
- appState: {
- selectedElementIds: makeNextSelectedElementIds(
- { [initializedImageElement.id]: true },
- this.state,
- ),
- },
- });
- return initializedImageElement;
- } catch (error: any) {
- this.store.scheduleAction(CaptureUpdateAction.NEVER);
- this.scene.mutateElement(placeholderImageElement, {
- isDeleted: true,
- });
- this.actionManager.executeAction(actionFinalize);
- this.setState({
- errorMessage: error.message || t("errors.imageInsertError"),
- });
- return null;
- }
- };
- private onImageAction = async () => {
- try {
- const clientX = this.state.width / 2 + this.state.offsetLeft;
- const clientY = this.state.height / 2 + this.state.offsetTop;
- const { x, y } = viewportCoordsToSceneCoords(
- { clientX, clientY },
- this.state,
- );
- const imageFile = await fileOpen({
- description: "Image",
- extensions: Object.keys(
- IMAGE_MIME_TYPES,
- ) as (keyof typeof IMAGE_MIME_TYPES)[],
- });
- await this.createImageElement({
- sceneX: x,
- sceneY: y,
- addToFrameUnderCursor: false,
- imageFile,
- });
- // avoid being batched (just in case)
- this.setState({}, () => {
- this.actionManager.executeAction(actionFinalize);
- });
- } catch (error: any) {
- if (error.name !== "AbortError") {
- console.error(error);
- } else {
- console.warn(error);
- }
- this.setState(
- {
- newElement: null,
- activeTool: updateActiveTool(this.state, { type: "selection" }),
- },
- () => {
- this.actionManager.executeAction(actionFinalize);
- },
- );
- }
- };
- private getImageNaturalDimensions = (
- imageElement: ExcalidrawImageElement,
- imageHTML: HTMLImageElement,
- ) => {
- const minHeight = Math.max(this.state.height - 120, 160);
- // max 65% of canvas height, clamped to <300px, vh - 120px>
- const maxHeight = Math.min(
- minHeight,
- Math.floor(this.state.height * 0.5) / this.state.zoom.value,
- );
- const height = Math.min(imageHTML.naturalHeight, maxHeight);
- const width = height * (imageHTML.naturalWidth / imageHTML.naturalHeight);
- // add current imageElement width/height to account for previous centering
- // of the placeholder image
- const x = imageElement.x + imageElement.width / 2 - width / 2;
- const y = imageElement.y + imageElement.height / 2 - height / 2;
- return {
- x,
- y,
- width,
- height,
- crop: null,
- };
- };
- /** updates image cache, refreshing updated elements and/or setting status
- to error for images that fail during <img> element creation */
- private updateImageCache = async (
- elements: readonly InitializedExcalidrawImageElement[],
- files = this.files,
- ) => {
- const { updatedFiles, erroredFiles } = await _updateImageCache({
- imageCache: this.imageCache,
- fileIds: elements.map((element) => element.fileId),
- files,
- });
- if (erroredFiles.size) {
- this.store.scheduleAction(CaptureUpdateAction.NEVER);
- this.scene.replaceAllElements(
- elements.map((element) => {
- if (
- isInitializedImageElement(element) &&
- erroredFiles.has(element.fileId)
- ) {
- return newElementWith(element, {
- status: "error",
- });
- }
- return element;
- }),
- );
- }
- return { updatedFiles, erroredFiles };
- };
- /** adds new images to imageCache and re-renders if needed */
- private addNewImagesToImageCache = async (
- imageElements: InitializedExcalidrawImageElement[] = getInitializedImageElements(
- this.scene.getNonDeletedElements(),
- ),
- files: BinaryFiles = this.files,
- ) => {
- const uncachedImageElements = imageElements.filter(
- (element) => !element.isDeleted && !this.imageCache.has(element.fileId),
- );
- if (uncachedImageElements.length) {
- const { updatedFiles } = await this.updateImageCache(
- uncachedImageElements,
- files,
- );
- if (updatedFiles.size) {
- for (const element of uncachedImageElements) {
- if (updatedFiles.has(element.fileId)) {
- ShapeCache.delete(element);
- }
- }
- }
- if (updatedFiles.size) {
- this.scene.triggerUpdate();
- }
- }
- };
- /** generally you should use `addNewImagesToImageCache()` directly if you need
- * to render new images. This is just a failsafe */
- private scheduleImageRefresh = throttle(() => {
- this.addNewImagesToImageCache();
- }, IMAGE_RENDER_TIMEOUT);
- private updateBindingEnabledOnPointerMove = (
- event: React.PointerEvent<HTMLElement>,
- ) => {
- const shouldEnableBinding = shouldEnableBindingForPointerEvent(event);
- if (this.state.isBindingEnabled !== shouldEnableBinding) {
- this.setState({ isBindingEnabled: shouldEnableBinding });
- }
- };
- private maybeSuggestBindingAtCursor = (
- pointerCoords: {
- x: number;
- y: number;
- },
- considerAll: boolean,
- ): void => {
- const hoveredBindableElement = getHoveredElementForBinding(
- pointerCoords,
- this.scene.getNonDeletedElements(),
- this.scene.getNonDeletedElementsMap(),
- this.state.zoom,
- false,
- considerAll,
- );
- this.setState({
- suggestedBindings:
- hoveredBindableElement != null ? [hoveredBindableElement] : [],
- });
- };
- private clearSelection(hitElement: ExcalidrawElement | null): void {
- this.setState((prevState) => ({
- selectedElementIds: makeNextSelectedElementIds({}, prevState),
- activeEmbeddable: null,
- selectedGroupIds: {},
- // Continue editing the same group if the user selected a different
- // element from it
- editingGroupId:
- prevState.editingGroupId &&
- hitElement != null &&
- isElementInGroup(hitElement, prevState.editingGroupId)
- ? prevState.editingGroupId
- : null,
- }));
- this.setState({
- selectedElementIds: makeNextSelectedElementIds({}, this.state),
- activeEmbeddable: null,
- previousSelectedElementIds: this.state.selectedElementIds,
- });
- }
- private handleInteractiveCanvasRef = (canvas: HTMLCanvasElement | null) => {
- // canvas is null when unmounting
- if (canvas !== null) {
- this.interactiveCanvas = canvas;
- // -----------------------------------------------------------------------
- // NOTE wheel, touchstart, touchend events must be registered outside
- // of react because react binds them them passively (so we can't prevent
- // default on them)
- this.interactiveCanvas.addEventListener(
- EVENT.TOUCH_START,
- this.onTouchStart,
- { passive: false },
- );
- this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd);
- // -----------------------------------------------------------------------
- } else {
- this.interactiveCanvas?.removeEventListener(
- EVENT.TOUCH_START,
- this.onTouchStart,
- );
- this.interactiveCanvas?.removeEventListener(
- EVENT.TOUCH_END,
- this.onTouchEnd,
- );
- }
- };
- private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
- // must be retrieved first, in the same frame
- const { file, fileHandle } = await getFileFromEvent(event);
- const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
- event,
- this.state,
- );
- try {
- // if image tool not supported, don't show an error here and let it fall
- // through so we still support importing scene data from images. If no
- // scene data encoded, we'll show an error then
- if (isSupportedImageFile(file) && this.isToolSupported("image")) {
- // first attempt to decode scene from the image if it's embedded
- // ---------------------------------------------------------------------
- if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) {
- try {
- const scene = await loadFromBlob(
- file,
- this.state,
- this.scene.getElementsIncludingDeleted(),
- fileHandle,
- );
- this.syncActionResult({
- ...scene,
- appState: {
- ...(scene.appState || this.state),
- isLoading: false,
- },
- replaceFiles: true,
- captureUpdate: CaptureUpdateAction.IMMEDIATELY,
- });
- return;
- } catch (error: any) {
- // Don't throw for image scene daa
- if (error.name !== "EncodingError") {
- throw new Error(t("alerts.couldNotLoadInvalidFile"));
- }
- }
- }
- // if no scene is embedded or we fail for whatever reason, fall back
- // to importing as regular image
- // ---------------------------------------------------------------------
- this.createImageElement({ sceneX, sceneY, imageFile: file });
- return;
- }
- } catch (error: any) {
- return this.setState({
- isLoading: false,
- errorMessage: error.message,
- });
- }
- const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
- if (libraryJSON && typeof libraryJSON === "string") {
- try {
- const libraryItems = parseLibraryJSON(libraryJSON);
- this.addElementsFromPasteOrLibrary({
- elements: distributeLibraryItemsOnSquareGrid(libraryItems),
- position: event,
- files: null,
- });
- } catch (error: any) {
- this.setState({ errorMessage: error.message });
- }
- return;
- }
- if (file) {
- // Attempt to parse an excalidraw/excalidrawlib file
- await this.loadFileToCanvas(file, fileHandle);
- }
- if (event.dataTransfer?.types?.includes("text/plain")) {
- const text = event.dataTransfer?.getData("text");
- if (
- text &&
- embeddableURLValidator(text, this.props.validateEmbeddable) &&
- (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(text) ||
- getEmbedLink(text)?.type === "video")
- ) {
- const embeddable = this.insertEmbeddableElement({
- sceneX,
- sceneY,
- link: normalizeLink(text),
- });
- if (embeddable) {
- this.store.scheduleCapture();
- this.setState({ selectedElementIds: { [embeddable.id]: true } });
- }
- }
- }
- };
- loadFileToCanvas = async (
- file: File,
- fileHandle: FileSystemHandle | null,
- ) => {
- file = await normalizeFile(file);
- try {
- const elements = this.scene.getElementsIncludingDeleted();
- let ret;
- try {
- ret = await loadSceneOrLibraryFromBlob(
- file,
- this.state,
- elements,
- fileHandle,
- );
- } catch (error: any) {
- const imageSceneDataError = error instanceof ImageSceneDataError;
- if (
- imageSceneDataError &&
- error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" &&
- !this.isToolSupported("image")
- ) {
- this.setState({
- isLoading: false,
- errorMessage: t("errors.imageToolNotSupported"),
- });
- return;
- }
- const errorMessage = imageSceneDataError
- ? t("alerts.cannotRestoreFromImage")
- : t("alerts.couldNotLoadInvalidFile");
- this.setState({
- isLoading: false,
- errorMessage,
- });
- }
- if (!ret) {
- return;
- }
- if (ret.type === MIME_TYPES.excalidraw) {
- // restore the fractional indices by mutating elements
- syncInvalidIndices(elements.concat(ret.data.elements));
- // don't capture and only update the store snapshot for old elements,
- // otherwise we would end up with duplicated fractional indices on undo
- this.store.scheduleMicroAction({
- action: CaptureUpdateAction.NEVER,
- elements,
- appState: undefined,
- });
- this.setState({ isLoading: true });
- this.syncActionResult({
- ...ret.data,
- appState: {
- ...(ret.data.appState || this.state),
- isLoading: false,
- },
- replaceFiles: true,
- captureUpdate: CaptureUpdateAction.IMMEDIATELY,
- });
- } else if (ret.type === MIME_TYPES.excalidrawlib) {
- await this.library
- .updateLibrary({
- libraryItems: file,
- merge: true,
- openLibraryMenu: true,
- })
- .catch((error) => {
- console.error(error);
- this.setState({ errorMessage: t("errors.importLibraryError") });
- });
- }
- } catch (error: any) {
- this.setState({ isLoading: false, errorMessage: error.message });
- }
- };
- private handleCanvasContextMenu = (
- event: React.MouseEvent<HTMLElement | HTMLCanvasElement>,
- ) => {
- event.preventDefault();
- if (
- (("pointerType" in event.nativeEvent &&
- event.nativeEvent.pointerType === "touch") ||
- ("pointerType" in event.nativeEvent &&
- event.nativeEvent.pointerType === "pen" &&
- // always allow if user uses a pen secondary button
- event.button !== POINTER_BUTTON.SECONDARY)) &&
- this.state.activeTool.type !== "selection"
- ) {
- return;
- }
- const { x, y } = viewportCoordsToSceneCoords(event, this.state);
- const element = this.getElementAtPosition(x, y, {
- preferSelected: true,
- includeLockedElements: true,
- });
- const selectedElements = this.scene.getSelectedElements(this.state);
- const isHittingCommonBoundBox =
- this.isHittingCommonBoundingBoxOfSelectedElements(
- { x, y },
- selectedElements,
- );
- const type = element || isHittingCommonBoundBox ? "element" : "canvas";
- const container = this.excalidrawContainerRef.current!;
- const { top: offsetTop, left: offsetLeft } =
- container.getBoundingClientRect();
- const left = event.clientX - offsetLeft;
- const top = event.clientY - offsetTop;
- trackEvent("contextMenu", "openContextMenu", type);
- this.setState(
- {
- ...(element && !this.state.selectedElementIds[element.id]
- ? {
- ...this.state,
- ...selectGroupsForSelectedElements(
- {
- editingGroupId: this.state.editingGroupId,
- selectedElementIds: { [element.id]: true },
- },
- this.scene.getNonDeletedElements(),
- this.state,
- this,
- ),
- selectedLinearElement: isLinearElement(element)
- ? new LinearElementEditor(
- element,
- this.scene.getNonDeletedElementsMap(),
- )
- : null,
- }
- : this.state),
- showHyperlinkPopup: false,
- },
- () => {
- this.setState({
- contextMenu: { top, left, items: this.getContextMenuItems(type) },
- });
- },
- );
- };
- private maybeDragNewGenericElement = (
- pointerDownState: PointerDownState,
- event: MouseEvent | KeyboardEvent,
- informMutation = true,
- ): void => {
- const selectionElement = this.state.selectionElement;
- const pointerCoords = pointerDownState.lastCoords;
- if (selectionElement && this.state.activeTool.type !== "eraser") {
- dragNewElement({
- newElement: selectionElement,
- elementType: this.state.activeTool.type,
- originX: pointerDownState.origin.x,
- originY: pointerDownState.origin.y,
- x: pointerCoords.x,
- y: pointerCoords.y,
- width: distance(pointerDownState.origin.x, pointerCoords.x),
- height: distance(pointerDownState.origin.y, pointerCoords.y),
- shouldMaintainAspectRatio: shouldMaintainAspectRatio(event),
- shouldResizeFromCenter: false,
- scene: this.scene,
- zoom: this.state.zoom.value,
- informMutation: false,
- });
- return;
- }
- const newElement = this.state.newElement;
- if (!newElement) {
- return;
- }
- let [gridX, gridY] = getGridPoint(
- pointerCoords.x,
- pointerCoords.y,
- event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
- );
- const image =
- isInitializedImageElement(newElement) &&
- this.imageCache.get(newElement.fileId)?.image;
- const aspectRatio =
- image && !(image instanceof Promise) ? image.width / image.height : null;
- this.maybeCacheReferenceSnapPoints(event, [newElement]);
- const { snapOffset, snapLines } = snapNewElement(
- newElement,
- this,
- event,
- {
- x:
- pointerDownState.originInGrid.x +
- (this.state.originSnapOffset?.x ?? 0),
- y:
- pointerDownState.originInGrid.y +
- (this.state.originSnapOffset?.y ?? 0),
- },
- {
- x: gridX - pointerDownState.originInGrid.x,
- y: gridY - pointerDownState.originInGrid.y,
- },
- this.scene.getNonDeletedElementsMap(),
- );
- gridX += snapOffset.x;
- gridY += snapOffset.y;
- this.setState({
- snapLines,
- });
- dragNewElement({
- newElement,
- elementType: this.state.activeTool.type,
- originX: pointerDownState.originInGrid.x,
- originY: pointerDownState.originInGrid.y,
- x: gridX,
- y: gridY,
- width: distance(pointerDownState.originInGrid.x, gridX),
- height: distance(pointerDownState.originInGrid.y, gridY),
- shouldMaintainAspectRatio: isImageElement(newElement)
- ? !shouldMaintainAspectRatio(event)
- : shouldMaintainAspectRatio(event),
- shouldResizeFromCenter: shouldResizeFromCenter(event),
- zoom: this.state.zoom.value,
- scene: this.scene,
- widthAspectRatio: aspectRatio,
- originOffset: this.state.originSnapOffset,
- informMutation,
- });
- this.setState({
- newElement,
- });
- // highlight elements that are to be added to frames on frames creation
- if (
- this.state.activeTool.type === TOOL_TYPE.frame ||
- this.state.activeTool.type === TOOL_TYPE.magicframe
- ) {
- this.setState({
- elementsToHighlight: getElementsInResizingFrame(
- this.scene.getNonDeletedElements(),
- newElement as ExcalidrawFrameLikeElement,
- this.state,
- this.scene.getNonDeletedElementsMap(),
- ),
- });
- }
- };
- private maybeHandleCrop = (
- pointerDownState: PointerDownState,
- event: MouseEvent | KeyboardEvent,
- ): boolean => {
- // to crop, we must already be in the cropping mode, where croppingElement has been set
- if (!this.state.croppingElementId) {
- return false;
- }
- const transformHandleType = pointerDownState.resize.handleType;
- const pointerCoords = pointerDownState.lastCoords;
- const [x, y] = getGridPoint(
- pointerCoords.x - pointerDownState.resize.offset.x,
- pointerCoords.y - pointerDownState.resize.offset.y,
- event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
- );
- const croppingElement = this.scene
- .getNonDeletedElementsMap()
- .get(this.state.croppingElementId);
- if (
- transformHandleType &&
- croppingElement &&
- isImageElement(croppingElement)
- ) {
- const croppingAtStateStart = pointerDownState.originalElements.get(
- croppingElement.id,
- );
- const image =
- isInitializedImageElement(croppingElement) &&
- this.imageCache.get(croppingElement.fileId)?.image;
- if (
- croppingAtStateStart &&
- isImageElement(croppingAtStateStart) &&
- image &&
- !(image instanceof Promise)
- ) {
- const [gridX, gridY] = getGridPoint(
- pointerCoords.x,
- pointerCoords.y,
- event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
- );
- const dragOffset = {
- x: gridX - pointerDownState.originInGrid.x,
- y: gridY - pointerDownState.originInGrid.y,
- };
- this.maybeCacheReferenceSnapPoints(event, [croppingElement]);
- const { snapOffset, snapLines } = snapResizingElements(
- [croppingElement],
- [croppingAtStateStart],
- this,
- event,
- dragOffset,
- transformHandleType,
- );
- this.scene.mutateElement(
- croppingElement,
- cropElement(
- croppingElement,
- this.scene.getNonDeletedElementsMap(),
- transformHandleType,
- image.naturalWidth,
- image.naturalHeight,
- x + snapOffset.x,
- y + snapOffset.y,
- event.shiftKey
- ? croppingAtStateStart.width / croppingAtStateStart.height
- : undefined,
- ),
- );
- updateBoundElements(croppingElement, this.scene, {
- newSize: {
- width: croppingElement.width,
- height: croppingElement.height,
- },
- });
- this.setState({
- isCropping: transformHandleType && transformHandleType !== "rotation",
- snapLines,
- });
- }
- return true;
- }
- return false;
- };
- private maybeHandleResize = (
- pointerDownState: PointerDownState,
- event: MouseEvent | KeyboardEvent,
- ): boolean => {
- const selectedElements = this.scene.getSelectedElements(this.state);
- const selectedFrames = selectedElements.filter(
- (element): element is ExcalidrawFrameLikeElement =>
- isFrameLikeElement(element),
- );
- const transformHandleType = pointerDownState.resize.handleType;
- if (
- // Frames cannot be rotated.
- (selectedFrames.length > 0 && transformHandleType === "rotation") ||
- // Elbow arrows cannot be transformed (resized or rotated).
- (selectedElements.length === 1 && isElbowArrow(selectedElements[0])) ||
- // Do not resize when in crop mode
- this.state.croppingElementId
- ) {
- return false;
- }
- this.setState({
- // TODO: rename this state field to "isScaling" to distinguish
- // it from the generic "isResizing" which includes scaling and
- // rotating
- isResizing: transformHandleType && transformHandleType !== "rotation",
- isRotating: transformHandleType === "rotation",
- activeEmbeddable: null,
- });
- const pointerCoords = pointerDownState.lastCoords;
- let [resizeX, resizeY] = getGridPoint(
- pointerCoords.x - pointerDownState.resize.offset.x,
- pointerCoords.y - pointerDownState.resize.offset.y,
- event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
- );
- const frameElementsOffsetsMap = new Map<
- string,
- {
- x: number;
- y: number;
- }
- >();
- selectedFrames.forEach((frame) => {
- const elementsInFrame = getFrameChildren(
- this.scene.getNonDeletedElements(),
- frame.id,
- );
- elementsInFrame.forEach((element) => {
- frameElementsOffsetsMap.set(frame.id + element.id, {
- x: element.x - frame.x,
- y: element.y - frame.y,
- });
- });
- });
- // check needed for avoiding flickering when a key gets pressed
- // during dragging
- if (!this.state.selectedElementsAreBeingDragged) {
- const [gridX, gridY] = getGridPoint(
- pointerCoords.x,
- pointerCoords.y,
- event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
- );
- const dragOffset = {
- x: gridX - pointerDownState.originInGrid.x,
- y: gridY - pointerDownState.originInGrid.y,
- };
- const originalElements = [...pointerDownState.originalElements.values()];
- this.maybeCacheReferenceSnapPoints(event, selectedElements);
- const { snapOffset, snapLines } = snapResizingElements(
- selectedElements,
- getSelectedElements(originalElements, this.state),
- this,
- event,
- dragOffset,
- transformHandleType,
- );
- resizeX += snapOffset.x;
- resizeY += snapOffset.y;
- this.setState({
- snapLines,
- });
- }
- if (
- transformElements(
- pointerDownState.originalElements,
- transformHandleType,
- selectedElements,
- this.scene,
- shouldRotateWithDiscreteAngle(event),
- shouldResizeFromCenter(event),
- selectedElements.some((element) => isImageElement(element))
- ? !shouldMaintainAspectRatio(event)
- : shouldMaintainAspectRatio(event),
- resizeX,
- resizeY,
- pointerDownState.resize.center.x,
- pointerDownState.resize.center.y,
- )
- ) {
- const suggestedBindings = getSuggestedBindingsForArrows(
- selectedElements,
- this.scene.getNonDeletedElementsMap(),
- this.state.zoom,
- );
- const elementsToHighlight = new Set<ExcalidrawElement>();
- selectedFrames.forEach((frame) => {
- getElementsInResizingFrame(
- this.scene.getNonDeletedElements(),
- frame,
- this.state,
- this.scene.getNonDeletedElementsMap(),
- ).forEach((element) => elementsToHighlight.add(element));
- });
- this.setState({
- elementsToHighlight: [...elementsToHighlight],
- suggestedBindings,
- });
- return true;
- }
- return false;
- };
- private getContextMenuItems = (
- type: "canvas" | "element",
- ): ContextMenuItems => {
- const options: ContextMenuItems = [];
- options.push(actionCopyAsPng, actionCopyAsSvg);
- // canvas contextMenu
- // -------------------------------------------------------------------------
- if (type === "canvas") {
- if (this.state.viewModeEnabled) {
- return [
- ...options,
- actionToggleGridMode,
- actionToggleZenMode,
- actionToggleViewMode,
- actionToggleStats,
- ];
- }
- return [
- actionPaste,
- CONTEXT_MENU_SEPARATOR,
- actionCopyAsPng,
- actionCopyAsSvg,
- copyText,
- CONTEXT_MENU_SEPARATOR,
- actionSelectAll,
- actionUnlockAllElements,
- CONTEXT_MENU_SEPARATOR,
- actionToggleGridMode,
- actionToggleObjectsSnapMode,
- actionToggleZenMode,
- actionToggleViewMode,
- actionToggleStats,
- ];
- }
- // element contextMenu
- // -------------------------------------------------------------------------
- options.push(copyText);
- if (this.state.viewModeEnabled) {
- return [actionCopy, ...options];
- }
- return [
- CONTEXT_MENU_SEPARATOR,
- actionCut,
- actionCopy,
- actionPaste,
- CONTEXT_MENU_SEPARATOR,
- actionSelectAllElementsInFrame,
- actionRemoveAllElementsFromFrame,
- actionWrapSelectionInFrame,
- CONTEXT_MENU_SEPARATOR,
- actionToggleCropEditor,
- CONTEXT_MENU_SEPARATOR,
- ...options,
- CONTEXT_MENU_SEPARATOR,
- actionCopyStyles,
- actionPasteStyles,
- CONTEXT_MENU_SEPARATOR,
- actionGroup,
- actionTextAutoResize,
- actionUnbindText,
- actionBindText,
- actionWrapTextInContainer,
- actionUngroup,
- CONTEXT_MENU_SEPARATOR,
- actionAddToLibrary,
- CONTEXT_MENU_SEPARATOR,
- actionSendBackward,
- actionBringForward,
- actionSendToBack,
- actionBringToFront,
- CONTEXT_MENU_SEPARATOR,
- actionFlipHorizontal,
- actionFlipVertical,
- CONTEXT_MENU_SEPARATOR,
- actionToggleLinearEditor,
- CONTEXT_MENU_SEPARATOR,
- actionLink,
- actionCopyElementLink,
- CONTEXT_MENU_SEPARATOR,
- actionDuplicateSelection,
- actionToggleElementLock,
- CONTEXT_MENU_SEPARATOR,
- actionDeleteSelected,
- ];
- };
- private handleWheel = withBatchedUpdates(
- (
- event: WheelEvent | React.WheelEvent<HTMLDivElement | HTMLCanvasElement>,
- ) => {
- // if not scrolling on canvas/wysiwyg, ignore
- if (
- !(
- event.target instanceof HTMLCanvasElement ||
- event.target instanceof HTMLTextAreaElement ||
- event.target instanceof HTMLIFrameElement
- )
- ) {
- // prevent zooming the browser (but allow scrolling DOM)
- if (event[KEYS.CTRL_OR_CMD]) {
- event.preventDefault();
- }
- return;
- }
- event.preventDefault();
- if (isPanning) {
- return;
- }
- const { deltaX, deltaY } = event;
- // note that event.ctrlKey is necessary to handle pinch zooming
- if (event.metaKey || event.ctrlKey) {
- const sign = Math.sign(deltaY);
- const MAX_STEP = ZOOM_STEP * 100;
- const absDelta = Math.abs(deltaY);
- let delta = deltaY;
- if (absDelta > MAX_STEP) {
- delta = MAX_STEP * sign;
- }
- let newZoom = this.state.zoom.value - delta / 100;
- // increase zoom steps the more zoomed-in we are (applies to >100% only)
- newZoom +=
- Math.log10(Math.max(1, this.state.zoom.value)) *
- -sign *
- // reduced amplification for small deltas (small movements on a trackpad)
- Math.min(1, absDelta / 20);
- this.translateCanvas((state) => ({
- ...getStateForZoom(
- {
- viewportX: this.lastViewportPosition.x,
- viewportY: this.lastViewportPosition.y,
- nextZoom: getNormalizedZoom(newZoom),
- },
- state,
- ),
- shouldCacheIgnoreZoom: true,
- }));
- this.resetShouldCacheIgnoreZoomDebounced();
- return;
- }
- // scroll horizontally when shift pressed
- if (event.shiftKey) {
- this.translateCanvas(({ zoom, scrollX }) => ({
- // on Mac, shift+wheel tends to result in deltaX
- scrollX: scrollX - (deltaY || deltaX) / zoom.value,
- }));
- return;
- }
- this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
- scrollX: scrollX - deltaX / zoom.value,
- scrollY: scrollY - deltaY / zoom.value,
- }));
- },
- );
- private getTextWysiwygSnappedToCenterPosition(
- x: number,
- y: number,
- appState: AppState,
- container?: ExcalidrawTextContainer | null,
- ) {
- if (container) {
- let elementCenterX = container.x + container.width / 2;
- let elementCenterY = container.y + container.height / 2;
- const elementCenter = getContainerCenter(
- container,
- appState,
- this.scene.getNonDeletedElementsMap(),
- );
- if (elementCenter) {
- elementCenterX = elementCenter.x;
- elementCenterY = elementCenter.y;
- }
- const distanceToCenter = Math.hypot(
- x - elementCenterX,
- y - elementCenterY,
- );
- const isSnappedToCenter =
- distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
- if (isSnappedToCenter) {
- const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
- { sceneX: elementCenterX, sceneY: elementCenterY },
- appState,
- );
- return { viewportX, viewportY, elementCenterX, elementCenterY };
- }
- }
- }
- private savePointer = (x: number, y: number, button: "up" | "down") => {
- if (!x || !y) {
- return;
- }
- const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
- { clientX: x, clientY: y },
- this.state,
- );
- if (isNaN(sceneX) || isNaN(sceneY)) {
- // sometimes the pointer goes off screen
- }
- const pointer: CollaboratorPointer = {
- x: sceneX,
- y: sceneY,
- tool: this.state.activeTool.type === "laser" ? "laser" : "pointer",
- };
- this.props.onPointerUpdate?.({
- pointer,
- button,
- pointersMap: gesture.pointers,
- });
- };
- private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
- if (!this.unmounted) {
- this.setState({ shouldCacheIgnoreZoom: false });
- }
- }, 300);
- private updateDOMRect = (cb?: () => void) => {
- if (this.excalidrawContainerRef?.current) {
- const excalidrawContainer = this.excalidrawContainerRef.current;
- const {
- width,
- height,
- left: offsetLeft,
- top: offsetTop,
- } = excalidrawContainer.getBoundingClientRect();
- const {
- width: currentWidth,
- height: currentHeight,
- offsetTop: currentOffsetTop,
- offsetLeft: currentOffsetLeft,
- } = this.state;
- if (
- width === currentWidth &&
- height === currentHeight &&
- offsetLeft === currentOffsetLeft &&
- offsetTop === currentOffsetTop
- ) {
- if (cb) {
- cb();
- }
- return;
- }
- this.setState(
- {
- width,
- height,
- offsetLeft,
- offsetTop,
- },
- () => {
- cb && cb();
- },
- );
- }
- };
- public refresh = () => {
- this.setState({ ...this.getCanvasOffsets() });
- };
- private getCanvasOffsets(): Pick<AppState, "offsetTop" | "offsetLeft"> {
- if (this.excalidrawContainerRef?.current) {
- const excalidrawContainer = this.excalidrawContainerRef.current;
- const { left, top } = excalidrawContainer.getBoundingClientRect();
- return {
- offsetLeft: left,
- offsetTop: top,
- };
- }
- return {
- offsetLeft: 0,
- offsetTop: 0,
- };
- }
- private async updateLanguage() {
- const currentLang =
- languages.find((lang) => lang.code === this.props.langCode) ||
- defaultLang;
- await setLanguage(currentLang);
- this.setAppState({});
- }
- }
- // -----------------------------------------------------------------------------
- // TEST HOOKS
- // -----------------------------------------------------------------------------
- declare global {
- interface Window {
- h: {
- scene: Scene;
- elements: readonly ExcalidrawElement[];
- state: AppState;
- setState: React.Component<any, AppState>["setState"];
- app: InstanceType<typeof App>;
- history: History;
- store: Store;
- };
- }
- }
- export const createTestHook = () => {
- if (isTestEnv() || isDevEnv()) {
- window.h = window.h || ({} as Window["h"]);
- Object.defineProperties(window.h, {
- elements: {
- configurable: true,
- get() {
- return this.app?.scene.getElementsIncludingDeleted();
- },
- set(elements: ExcalidrawElement[]) {
- return this.app?.scene.replaceAllElements(
- syncInvalidIndices(elements),
- );
- },
- },
- scene: {
- configurable: true,
- get() {
- return this.app?.scene;
- },
- },
- });
- }
- };
- createTestHook();
- export default App;
|