App.tsx 120 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064
  1. import React from "react";
  2. import rough from "roughjs/bin/rough";
  3. import { RoughCanvas } from "roughjs/bin/canvas";
  4. import { simplify, Point } from "points-on-curve";
  5. import {
  6. newElement,
  7. newTextElement,
  8. duplicateElement,
  9. isInvisiblySmallElement,
  10. isTextElement,
  11. textWysiwyg,
  12. getCommonBounds,
  13. getCursorForResizingElement,
  14. getPerfectElementSize,
  15. getNormalizedDimensions,
  16. getSceneVersion,
  17. getSyncableElements,
  18. newLinearElement,
  19. transformElements,
  20. getElementWithTransformHandleType,
  21. getResizeOffsetXY,
  22. getResizeArrowDirection,
  23. getTransformHandleTypeFromCoords,
  24. isNonDeletedElement,
  25. updateTextElement,
  26. dragSelectedElements,
  27. getDragOffsetXY,
  28. dragNewElement,
  29. hitTest,
  30. isHittingElementBoundingBoxWithoutHittingElement,
  31. getNonDeletedElements,
  32. } from "../element";
  33. import {
  34. getElementsWithinSelection,
  35. isOverScrollBars,
  36. getElementsAtPosition,
  37. getElementContainingPosition,
  38. getNormalizedZoom,
  39. getSelectedElements,
  40. isSomeElementSelected,
  41. calculateScrollCenter,
  42. } from "../scene";
  43. import {
  44. decryptAESGEM,
  45. loadScene,
  46. loadFromBlob,
  47. SOCKET_SERVER,
  48. SocketUpdateDataSource,
  49. exportCanvas,
  50. } from "../data";
  51. import Portal from "./Portal";
  52. import { renderScene } from "../renderer";
  53. import { AppState, GestureEvent, Gesture, ExcalidrawProps } from "../types";
  54. import {
  55. ExcalidrawElement,
  56. ExcalidrawTextElement,
  57. NonDeleted,
  58. ExcalidrawGenericElement,
  59. ExcalidrawLinearElement,
  60. ExcalidrawBindableElement,
  61. } from "../element/types";
  62. import { distance2d, isPathALoop, getGridPoint } from "../math";
  63. import {
  64. isWritableElement,
  65. isInputLike,
  66. isToolIcon,
  67. debounce,
  68. distance,
  69. resetCursor,
  70. viewportCoordsToSceneCoords,
  71. sceneCoordsToViewportCoords,
  72. setCursorForShape,
  73. tupleToCoors,
  74. } from "../utils";
  75. import {
  76. KEYS,
  77. isArrowKey,
  78. getResizeCenterPointKey,
  79. getResizeWithSidesSameLengthKey,
  80. getRotateWithDiscreteAngleKey,
  81. } from "../keys";
  82. import { findShapeByKey } from "../shapes";
  83. import { createHistory, SceneHistory } from "../history";
  84. import ContextMenu from "./ContextMenu";
  85. import { ActionManager } from "../actions/manager";
  86. import "../actions";
  87. import { actions } from "../actions/register";
  88. import { ActionResult } from "../actions/types";
  89. import { getDefaultAppState } from "../appState";
  90. import { t, getLanguage } from "../i18n";
  91. import {
  92. copyToClipboard,
  93. parseClipboard,
  94. probablySupportsClipboardBlob,
  95. probablySupportsClipboardWriteText,
  96. } from "../clipboard";
  97. import { normalizeScroll } from "../scene";
  98. import { getCenter, getDistance } from "../gesture";
  99. import { createUndoAction, createRedoAction } from "../actions/actionHistory";
  100. import {
  101. CURSOR_TYPE,
  102. ELEMENT_SHIFT_TRANSLATE_AMOUNT,
  103. ELEMENT_TRANSLATE_AMOUNT,
  104. POINTER_BUTTON,
  105. DRAGGING_THRESHOLD,
  106. TEXT_TO_CENTER_SNAP_THRESHOLD,
  107. LINE_CONFIRM_THRESHOLD,
  108. SCENE,
  109. EVENT,
  110. ENV,
  111. CANVAS_ONLY_ACTIONS,
  112. DEFAULT_VERTICAL_ALIGN,
  113. GRID_SIZE,
  114. LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
  115. MIME_TYPES,
  116. } from "../constants";
  117. import {
  118. INITIAL_SCENE_UPDATE_TIMEOUT,
  119. TAP_TWICE_TIMEOUT,
  120. SYNC_FULL_SCENE_INTERVAL_MS,
  121. TOUCH_CTX_MENU_TIMEOUT,
  122. } from "../time_constants";
  123. import LayerUI from "./LayerUI";
  124. import { ScrollBars, SceneState } from "../scene/types";
  125. import { generateCollaborationLink, getCollaborationLinkData } from "../data";
  126. import { mutateElement } from "../element/mutateElement";
  127. import { invalidateShapeForElement } from "../renderer/renderElement";
  128. import { unstable_batchedUpdates } from "react-dom";
  129. import {
  130. isLinearElement,
  131. isLinearElementType,
  132. isBindingElement,
  133. isBindingElementType,
  134. } from "../element/typeChecks";
  135. import { actionFinalize, actionDeleteSelected } from "../actions";
  136. import { loadLibrary } from "../data/localStorage";
  137. import throttle from "lodash.throttle";
  138. import { LinearElementEditor } from "../element/linearElementEditor";
  139. import {
  140. getSelectedGroupIds,
  141. isSelectedViaGroup,
  142. selectGroupsForSelectedElements,
  143. isElementInGroup,
  144. getSelectedGroupIdForElement,
  145. getElementsInGroup,
  146. editGroupForSelectedElement,
  147. } from "../groups";
  148. import { Library } from "../data/library";
  149. import Scene from "../scene/Scene";
  150. import {
  151. getHoveredElementForBinding,
  152. maybeBindLinearElement,
  153. getEligibleElementsForBinding,
  154. bindOrUnbindSelectedElements,
  155. unbindLinearElements,
  156. fixBindingsAfterDuplication,
  157. fixBindingsAfterDeletion,
  158. isLinearElementSimpleAndAlreadyBound,
  159. isBindingEnabled,
  160. updateBoundElements,
  161. shouldEnableBindingForPointerEvent,
  162. } from "../element/binding";
  163. import { MaybeTransformHandleType } from "../element/transformHandles";
  164. import { renderSpreadsheet } from "../charts";
  165. import { isValidLibrary } from "../data/json";
  166. import {
  167. loadFromFirebase,
  168. saveToFirebase,
  169. isSavedToFirebase,
  170. } from "../data/firebase";
  171. /**
  172. * @param func handler taking at most single parameter (event).
  173. */
  174. const withBatchedUpdates = <
  175. TFunction extends ((event: any) => void) | (() => void)
  176. >(
  177. func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
  178. ) =>
  179. ((event) => {
  180. unstable_batchedUpdates(func as TFunction, event);
  181. }) as TFunction;
  182. const { history } = createHistory();
  183. let didTapTwice: boolean = false;
  184. let tappedTwiceTimer = 0;
  185. let cursorX = 0;
  186. let cursorY = 0;
  187. let isHoldingSpace: boolean = false;
  188. let isPanning: boolean = false;
  189. let isDraggingScrollBar: boolean = false;
  190. let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
  191. let touchTimeout = 0;
  192. let touchMoving = false;
  193. let lastPointerUp: ((event: any) => void) | null = null;
  194. const gesture: Gesture = {
  195. pointers: new Map(),
  196. lastCenter: null,
  197. initialDistance: null,
  198. initialScale: null,
  199. };
  200. export type PointerDownState = Readonly<{
  201. // The first position at which pointerDown happened
  202. origin: Readonly<{ x: number; y: number }>;
  203. // Same as "origin" but snapped to the grid, if grid is on
  204. originInGrid: Readonly<{ x: number; y: number }>;
  205. // Scrollbar checks
  206. scrollbars: ReturnType<typeof isOverScrollBars>;
  207. // The previous pointer position
  208. lastCoords: { x: number; y: number };
  209. // map of original elements data
  210. // (for now only a subset of props for perf reasons)
  211. originalElements: Map<string, Pick<ExcalidrawElement, "x" | "y" | "angle">>;
  212. resize: {
  213. // Handle when resizing, might change during the pointer interaction
  214. handleType: MaybeTransformHandleType;
  215. // This is determined on the initial pointer down event
  216. isResizing: boolean;
  217. // This is determined on the initial pointer down event
  218. offset: { x: number; y: number };
  219. // This is determined on the initial pointer down event
  220. arrowDirection: "origin" | "end";
  221. // This is a center point of selected elements determined on the initial pointer down event (for rotation only)
  222. center: { x: number; y: number };
  223. };
  224. hit: {
  225. // The element the pointer is "hitting", is determined on the initial
  226. // pointer down event
  227. element: NonDeleted<ExcalidrawElement> | null;
  228. // The elements the pointer is "hitting", is determined on the initial
  229. // pointer down event
  230. allHitElements: NonDeleted<ExcalidrawElement>[];
  231. // This is determined on the initial pointer down event
  232. wasAddedToSelection: boolean;
  233. // Whether selected element(s) were duplicated, might change during the
  234. // pointer interaction
  235. hasBeenDuplicated: boolean;
  236. hasHitCommonBoundingBoxOfSelectedElements: boolean;
  237. };
  238. drag: {
  239. // Might change during the pointer interation
  240. hasOccurred: boolean;
  241. // Might change during the pointer interation
  242. offset: { x: number; y: number } | null;
  243. };
  244. // We need to have these in the state so that we can unsubscribe them
  245. eventListeners: {
  246. // It's defined on the initial pointer down event
  247. onMove: null | ((event: PointerEvent) => void);
  248. // It's defined on the initial pointer down event
  249. onUp: null | ((event: PointerEvent) => void);
  250. };
  251. }>;
  252. export type ExcalidrawImperativeAPI =
  253. | {
  254. updateScene: InstanceType<typeof App>["updateScene"];
  255. resetScene: InstanceType<typeof App>["resetScene"];
  256. resetHistory: InstanceType<typeof App>["resetHistory"];
  257. getSceneElementsIncludingDeleted: InstanceType<
  258. typeof App
  259. >["getSceneElementsIncludingDeleted"];
  260. }
  261. | undefined;
  262. class App extends React.Component<ExcalidrawProps, AppState> {
  263. canvas: HTMLCanvasElement | null = null;
  264. rc: RoughCanvas | null = null;
  265. portal: Portal;
  266. private lastBroadcastedOrReceivedSceneVersion: number = -1;
  267. unmounted: boolean = false;
  268. actionManager: ActionManager;
  269. private excalidrawRef: any;
  270. private socketInitializationTimer: any;
  271. public static defaultProps: Partial<ExcalidrawProps> = {
  272. width: window.innerWidth,
  273. height: window.innerHeight,
  274. };
  275. private scene: Scene;
  276. constructor(props: ExcalidrawProps) {
  277. super(props);
  278. const defaultAppState = getDefaultAppState();
  279. const { width, height, offsetLeft, offsetTop, user, forwardedRef } = props;
  280. this.state = {
  281. ...defaultAppState,
  282. isLoading: true,
  283. width,
  284. height,
  285. username: user?.name || "",
  286. ...this.getCanvasOffsets({ offsetLeft, offsetTop }),
  287. };
  288. if (forwardedRef && "current" in forwardedRef) {
  289. forwardedRef.current = {
  290. updateScene: this.updateScene,
  291. resetScene: this.resetScene,
  292. resetHistory: this.resetHistory,
  293. getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
  294. };
  295. }
  296. this.scene = new Scene();
  297. this.portal = new Portal(this);
  298. this.excalidrawRef = React.createRef();
  299. this.actionManager = new ActionManager(
  300. this.syncActionResult,
  301. () => this.state,
  302. () => this.scene.getElementsIncludingDeleted(),
  303. );
  304. this.actionManager.registerAll(actions);
  305. this.actionManager.registerAction(createUndoAction(history));
  306. this.actionManager.registerAction(createRedoAction(history));
  307. }
  308. public render() {
  309. const {
  310. zenModeEnabled,
  311. width: canvasDOMWidth,
  312. height: canvasDOMHeight,
  313. offsetTop,
  314. offsetLeft,
  315. } = this.state;
  316. const { onUsernameChange } = this.props;
  317. const canvasScale = window.devicePixelRatio;
  318. const canvasWidth = canvasDOMWidth * canvasScale;
  319. const canvasHeight = canvasDOMHeight * canvasScale;
  320. return (
  321. <div
  322. className="excalidraw"
  323. ref={this.excalidrawRef}
  324. style={{
  325. width: canvasDOMWidth,
  326. height: canvasDOMHeight,
  327. top: offsetTop,
  328. left: offsetLeft,
  329. }}
  330. >
  331. <LayerUI
  332. canvas={this.canvas}
  333. appState={this.state}
  334. setAppState={this.setAppState}
  335. actionManager={this.actionManager}
  336. elements={this.scene.getElements()}
  337. onRoomCreate={this.openPortal}
  338. onRoomDestroy={this.closePortal}
  339. onUsernameChange={(username) => {
  340. onUsernameChange && onUsernameChange(username);
  341. this.setState({ username });
  342. }}
  343. onLockToggle={this.toggleLock}
  344. onInsertShape={(elements) =>
  345. this.addElementsFromPasteOrLibrary(elements)
  346. }
  347. zenModeEnabled={zenModeEnabled}
  348. toggleZenMode={this.toggleZenMode}
  349. lng={getLanguage().lng}
  350. />
  351. <main>
  352. <canvas
  353. id="canvas"
  354. style={{
  355. width: canvasDOMWidth,
  356. height: canvasDOMHeight,
  357. }}
  358. width={canvasWidth}
  359. height={canvasHeight}
  360. ref={this.handleCanvasRef}
  361. onContextMenu={this.handleCanvasContextMenu}
  362. onPointerDown={this.handleCanvasPointerDown}
  363. onDoubleClick={this.handleCanvasDoubleClick}
  364. onPointerMove={this.handleCanvasPointerMove}
  365. onPointerUp={this.removePointer}
  366. onPointerCancel={this.removePointer}
  367. onTouchMove={this.handleTouchMove}
  368. onDrop={this.handleCanvasOnDrop}
  369. >
  370. {t("labels.drawingCanvas")}
  371. </canvas>
  372. </main>
  373. </div>
  374. );
  375. }
  376. public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
  377. this.lastBroadcastedOrReceivedSceneVersion = version;
  378. };
  379. public getLastBroadcastedOrReceivedSceneVersion = () => {
  380. return this.lastBroadcastedOrReceivedSceneVersion;
  381. };
  382. public getSceneElementsIncludingDeleted = () => {
  383. return this.scene.getElementsIncludingDeleted();
  384. };
  385. private syncActionResult = withBatchedUpdates(
  386. (actionResult: ActionResult) => {
  387. if (this.unmounted || actionResult === false) {
  388. return;
  389. }
  390. let editingElement: AppState["editingElement"] | null = null;
  391. if (actionResult.elements) {
  392. actionResult.elements.forEach((element) => {
  393. if (
  394. this.state.editingElement?.id === element.id &&
  395. this.state.editingElement !== element &&
  396. isNonDeletedElement(element)
  397. ) {
  398. editingElement = element;
  399. }
  400. });
  401. this.scene.replaceAllElements(actionResult.elements);
  402. if (actionResult.commitToHistory) {
  403. history.resumeRecording();
  404. }
  405. }
  406. if (actionResult.appState || editingElement) {
  407. if (actionResult.commitToHistory) {
  408. history.resumeRecording();
  409. }
  410. this.setState(
  411. (state) => ({
  412. ...actionResult.appState,
  413. editingElement:
  414. editingElement || actionResult.appState?.editingElement || null,
  415. isCollaborating: state.isCollaborating,
  416. collaborators: state.collaborators,
  417. width: state.width,
  418. height: state.height,
  419. offsetTop: state.offsetTop,
  420. offsetLeft: state.offsetLeft,
  421. }),
  422. () => {
  423. if (actionResult.syncHistory) {
  424. history.setCurrentState(
  425. this.state,
  426. this.scene.getElementsIncludingDeleted(),
  427. );
  428. }
  429. },
  430. );
  431. }
  432. },
  433. );
  434. // Lifecycle
  435. private onBlur = withBatchedUpdates(() => {
  436. isHoldingSpace = false;
  437. this.setState({ isBindingEnabled: true });
  438. });
  439. private onUnload = () => {
  440. this.destroySocketClient();
  441. this.onBlur();
  442. };
  443. private disableEvent: EventHandlerNonNull = (event) => {
  444. event.preventDefault();
  445. };
  446. private onFontLoaded = () => {
  447. this.scene.getElementsIncludingDeleted().forEach((element) => {
  448. if (isTextElement(element)) {
  449. invalidateShapeForElement(element);
  450. }
  451. });
  452. this.onSceneUpdated();
  453. };
  454. private shouldForceLoadScene(
  455. scene: ResolutionType<typeof loadScene>,
  456. ): boolean {
  457. if (!scene.elements.length) {
  458. return true;
  459. }
  460. const roomMatch = getCollaborationLinkData(window.location.href);
  461. if (!roomMatch) {
  462. return false;
  463. }
  464. const roomID = roomMatch[1];
  465. let collabForceLoadFlag;
  466. try {
  467. collabForceLoadFlag = localStorage?.getItem(
  468. LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
  469. );
  470. } catch {}
  471. if (collabForceLoadFlag) {
  472. try {
  473. const {
  474. room: previousRoom,
  475. timestamp,
  476. }: { room: string; timestamp: number } = JSON.parse(
  477. collabForceLoadFlag,
  478. );
  479. // if loading same room as the one previously unloaded within 15sec
  480. // force reload without prompting
  481. if (previousRoom === roomID && Date.now() - timestamp < 15000) {
  482. return true;
  483. }
  484. } catch {}
  485. }
  486. return false;
  487. }
  488. private addToLibrary = async (url: string) => {
  489. window.history.replaceState({}, "Excalidraw", window.location.origin);
  490. try {
  491. const request = await fetch(url);
  492. const blob = await request.blob();
  493. const json = JSON.parse(await blob.text());
  494. if (!isValidLibrary(json)) {
  495. throw new Error();
  496. }
  497. if (
  498. window.confirm(
  499. t("alerts.confirmAddLibrary", { numShapes: json.library.length }),
  500. )
  501. ) {
  502. await Library.importLibrary(blob);
  503. this.setState({
  504. isLibraryOpen: true,
  505. });
  506. }
  507. } catch (error) {
  508. window.alert(t("alerts.errorLoadingLibrary"));
  509. console.error(error);
  510. }
  511. };
  512. private resetHistory = () => {
  513. history.clear();
  514. };
  515. /** Completely resets scene & history.
  516. * Do not use for clear scene user action. */
  517. private resetScene = withBatchedUpdates(() => {
  518. this.scene.replaceAllElements([]);
  519. this.setState({
  520. ...getDefaultAppState(),
  521. appearance: this.state.appearance,
  522. username: this.state.username,
  523. });
  524. this.resetHistory();
  525. });
  526. private initializeScene = async () => {
  527. if ("launchQueue" in window && "LaunchParams" in window) {
  528. (window as any).launchQueue.setConsumer(
  529. async (launchParams: { files: any[] }) => {
  530. if (!launchParams.files.length) {
  531. return;
  532. }
  533. const fileHandle = launchParams.files[0];
  534. const blob: Blob = await fileHandle.getFile();
  535. blob.handle = fileHandle;
  536. loadFromBlob(blob, this.state)
  537. .then(({ elements, appState }) =>
  538. this.syncActionResult({
  539. elements,
  540. appState: {
  541. ...(appState || this.state),
  542. isLoading: false,
  543. },
  544. commitToHistory: true,
  545. }),
  546. )
  547. .catch((error) => {
  548. this.setState({ isLoading: false, errorMessage: error.message });
  549. });
  550. },
  551. );
  552. }
  553. const searchParams = new URLSearchParams(window.location.search);
  554. const id = searchParams.get("id");
  555. const jsonMatch = window.location.hash.match(
  556. /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
  557. );
  558. if (!this.state.isLoading) {
  559. this.setState({ isLoading: true });
  560. }
  561. let scene = await loadScene(null, null, this.props.initialData);
  562. let isCollaborationScene = !!getCollaborationLinkData(window.location.href);
  563. const isExternalScene = !!(id || jsonMatch || isCollaborationScene);
  564. if (isExternalScene) {
  565. if (
  566. this.shouldForceLoadScene(scene) ||
  567. window.confirm(t("alerts.loadSceneOverridePrompt"))
  568. ) {
  569. // Backwards compatibility with legacy url format
  570. if (id) {
  571. scene = await loadScene(id, null, this.props.initialData);
  572. } else if (jsonMatch) {
  573. scene = await loadScene(
  574. jsonMatch[1],
  575. jsonMatch[2],
  576. this.props.initialData,
  577. );
  578. }
  579. if (!isCollaborationScene) {
  580. window.history.replaceState({}, "Excalidraw", window.location.origin);
  581. }
  582. } else {
  583. // https://github.com/excalidraw/excalidraw/issues/1919
  584. if (document.hidden) {
  585. window.addEventListener("focus", () => this.initializeScene(), {
  586. once: true,
  587. });
  588. return;
  589. }
  590. isCollaborationScene = false;
  591. window.history.replaceState({}, "Excalidraw", window.location.origin);
  592. }
  593. }
  594. if (this.state.isLoading) {
  595. this.setState({ isLoading: false });
  596. }
  597. if (isCollaborationScene) {
  598. // when joining a room we don't want user's local scene data to be merged
  599. // into the remote scene, so set `clearScene`
  600. this.initializeSocketClient({ showLoadingState: true, clearScene: true });
  601. } else if (scene) {
  602. if (scene.appState) {
  603. scene.appState = {
  604. ...scene.appState,
  605. ...calculateScrollCenter(
  606. scene.elements,
  607. {
  608. ...scene.appState,
  609. offsetTop: this.state.offsetTop,
  610. offsetLeft: this.state.offsetLeft,
  611. },
  612. null,
  613. ),
  614. };
  615. }
  616. this.resetHistory();
  617. this.syncActionResult({
  618. ...scene,
  619. commitToHistory: true,
  620. });
  621. }
  622. const addToLibraryUrl = searchParams.get("addLibrary");
  623. if (addToLibraryUrl) {
  624. await this.addToLibrary(addToLibraryUrl);
  625. }
  626. };
  627. public async componentDidMount() {
  628. if (
  629. process.env.NODE_ENV === ENV.TEST ||
  630. process.env.NODE_ENV === ENV.DEVELOPMENT
  631. ) {
  632. const setState = this.setState.bind(this);
  633. Object.defineProperties(window.h, {
  634. state: {
  635. configurable: true,
  636. get: () => {
  637. return this.state;
  638. },
  639. },
  640. setState: {
  641. configurable: true,
  642. value: (...args: Parameters<typeof setState>) => {
  643. return this.setState(...args);
  644. },
  645. },
  646. app: {
  647. configurable: true,
  648. value: this,
  649. },
  650. });
  651. }
  652. this.scene.addCallback(this.onSceneUpdated);
  653. this.addEventListeners();
  654. // optim to avoid extra render on init
  655. if (
  656. typeof this.props.offsetLeft === "number" &&
  657. typeof this.props.offsetTop === "number"
  658. ) {
  659. this.initializeScene();
  660. } else {
  661. this.setState(this.getCanvasOffsets(this.props), () => {
  662. this.initializeScene();
  663. });
  664. }
  665. }
  666. public componentWillUnmount() {
  667. this.unmounted = true;
  668. this.removeEventListeners();
  669. this.scene.destroy();
  670. clearTimeout(touchTimeout);
  671. }
  672. private onResize = withBatchedUpdates(() => {
  673. this.scene
  674. .getElementsIncludingDeleted()
  675. .forEach((element) => invalidateShapeForElement(element));
  676. this.setState({});
  677. });
  678. private onHashChange = (_: HashChangeEvent) => {
  679. if (window.location.hash.length > 1) {
  680. this.initializeScene();
  681. }
  682. };
  683. private removeEventListeners() {
  684. document.removeEventListener(EVENT.COPY, this.onCopy);
  685. document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
  686. document.removeEventListener(EVENT.CUT, this.onCut);
  687. document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
  688. document.removeEventListener(
  689. EVENT.MOUSE_MOVE,
  690. this.updateCurrentCursorPosition,
  691. false,
  692. );
  693. document.removeEventListener(EVENT.KEYUP, this.onKeyUp);
  694. window.removeEventListener(EVENT.RESIZE, this.onResize, false);
  695. window.removeEventListener(EVENT.UNLOAD, this.onUnload, false);
  696. window.removeEventListener(EVENT.BLUR, this.onBlur, false);
  697. window.removeEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
  698. window.removeEventListener(EVENT.DROP, this.disableEvent, false);
  699. window.removeEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
  700. document.removeEventListener(
  701. EVENT.GESTURE_START,
  702. this.onGestureStart as any,
  703. false,
  704. );
  705. document.removeEventListener(
  706. EVENT.GESTURE_CHANGE,
  707. this.onGestureChange as any,
  708. false,
  709. );
  710. document.removeEventListener(
  711. EVENT.GESTURE_END,
  712. this.onGestureEnd as any,
  713. false,
  714. );
  715. window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  716. }
  717. private addEventListeners() {
  718. document.addEventListener(EVENT.COPY, this.onCopy);
  719. document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
  720. document.addEventListener(EVENT.CUT, this.onCut);
  721. document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
  722. document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
  723. document.addEventListener(
  724. EVENT.MOUSE_MOVE,
  725. this.updateCurrentCursorPosition,
  726. );
  727. window.addEventListener(EVENT.RESIZE, this.onResize, false);
  728. window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
  729. window.addEventListener(EVENT.BLUR, this.onBlur, false);
  730. window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
  731. window.addEventListener(EVENT.DROP, this.disableEvent, false);
  732. window.addEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
  733. // rerender text elements on font load to fix #637 && #1553
  734. document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
  735. // Safari-only desktop pinch zoom
  736. document.addEventListener(
  737. EVENT.GESTURE_START,
  738. this.onGestureStart as any,
  739. false,
  740. );
  741. document.addEventListener(
  742. EVENT.GESTURE_CHANGE,
  743. this.onGestureChange as any,
  744. false,
  745. );
  746. document.addEventListener(
  747. EVENT.GESTURE_END,
  748. this.onGestureEnd as any,
  749. false,
  750. );
  751. window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  752. }
  753. private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
  754. if (this.state.isCollaborating && this.portal.roomID) {
  755. try {
  756. localStorage?.setItem(
  757. LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
  758. JSON.stringify({
  759. timestamp: Date.now(),
  760. room: this.portal.roomID,
  761. }),
  762. );
  763. } catch {}
  764. }
  765. const syncableElements = getSyncableElements(
  766. this.scene.getElementsIncludingDeleted(),
  767. );
  768. if (
  769. this.state.isCollaborating &&
  770. !isSavedToFirebase(this.portal, syncableElements)
  771. ) {
  772. // this won't run in time if user decides to leave the site, but
  773. // the purpose is to run in immediately after user decides to stay
  774. this.saveCollabRoomToFirebase(syncableElements);
  775. event.preventDefault();
  776. // NOTE: modern browsers no longer allow showing a custom message here
  777. event.returnValue = "";
  778. }
  779. });
  780. queueBroadcastAllElements = throttle(() => {
  781. this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
  782. }, SYNC_FULL_SCENE_INTERVAL_MS);
  783. componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
  784. if (
  785. prevProps.width !== this.props.width ||
  786. prevProps.height !== this.props.height ||
  787. (typeof this.props.offsetLeft === "number" &&
  788. prevProps.offsetLeft !== this.props.offsetLeft) ||
  789. (typeof this.props.offsetTop === "number" &&
  790. prevProps.offsetTop !== this.props.offsetTop)
  791. ) {
  792. this.setState({
  793. width: this.props.width,
  794. height: this.props.height,
  795. ...this.getCanvasOffsets(this.props),
  796. });
  797. }
  798. document
  799. .querySelector(".excalidraw")
  800. ?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
  801. if (
  802. this.state.editingLinearElement &&
  803. !this.state.selectedElementIds[this.state.editingLinearElement.elementId]
  804. ) {
  805. // defer so that the commitToHistory flag isn't reset via current update
  806. setTimeout(() => {
  807. this.actionManager.executeAction(actionFinalize);
  808. });
  809. }
  810. const { multiElement } = prevState;
  811. if (
  812. prevState.elementType !== this.state.elementType &&
  813. multiElement != null &&
  814. isBindingEnabled(this.state) &&
  815. isBindingElement(multiElement)
  816. ) {
  817. maybeBindLinearElement(
  818. multiElement,
  819. this.state,
  820. this.scene,
  821. tupleToCoors(
  822. LinearElementEditor.getPointAtIndexGlobalCoordinates(
  823. multiElement,
  824. -1,
  825. ),
  826. ),
  827. );
  828. }
  829. const cursorButton: {
  830. [id: string]: string | undefined;
  831. } = {};
  832. const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
  833. const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
  834. const pointerUsernames: { [id: string]: string } = {};
  835. this.state.collaborators.forEach((user, socketId) => {
  836. if (user.selectedElementIds) {
  837. for (const id of Object.keys(user.selectedElementIds)) {
  838. if (!(id in remoteSelectedElementIds)) {
  839. remoteSelectedElementIds[id] = [];
  840. }
  841. remoteSelectedElementIds[id].push(socketId);
  842. }
  843. }
  844. if (!user.pointer) {
  845. return;
  846. }
  847. if (user.username) {
  848. pointerUsernames[socketId] = user.username;
  849. }
  850. pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
  851. {
  852. sceneX: user.pointer.x,
  853. sceneY: user.pointer.y,
  854. },
  855. this.state,
  856. this.canvas,
  857. window.devicePixelRatio,
  858. );
  859. cursorButton[socketId] = user.button;
  860. });
  861. const elements = this.scene.getElements();
  862. const { atLeastOneVisibleElement, scrollBars } = renderScene(
  863. elements.filter((element) => {
  864. // don't render text element that's being currently edited (it's
  865. // rendered on remote only)
  866. return (
  867. !this.state.editingElement ||
  868. this.state.editingElement.type !== "text" ||
  869. element.id !== this.state.editingElement.id
  870. );
  871. }),
  872. this.state,
  873. this.state.selectionElement,
  874. window.devicePixelRatio,
  875. this.rc!,
  876. this.canvas!,
  877. {
  878. scrollX: this.state.scrollX,
  879. scrollY: this.state.scrollY,
  880. viewBackgroundColor: this.state.viewBackgroundColor,
  881. zoom: this.state.zoom,
  882. remotePointerViewportCoords: pointerViewportCoords,
  883. remotePointerButton: cursorButton,
  884. remoteSelectedElementIds: remoteSelectedElementIds,
  885. remotePointerUsernames: pointerUsernames,
  886. shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
  887. },
  888. {
  889. renderOptimizations: true,
  890. },
  891. );
  892. if (scrollBars) {
  893. currentScrollBars = scrollBars;
  894. }
  895. const scrolledOutside =
  896. // hide when editing text
  897. this.state.editingElement?.type === "text"
  898. ? false
  899. : !atLeastOneVisibleElement && elements.length > 0;
  900. if (this.state.scrolledOutside !== scrolledOutside) {
  901. this.setState({ scrolledOutside: scrolledOutside });
  902. }
  903. if (
  904. getSceneVersion(this.scene.getElementsIncludingDeleted()) >
  905. this.lastBroadcastedOrReceivedSceneVersion
  906. ) {
  907. this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ false);
  908. this.queueBroadcastAllElements();
  909. }
  910. history.record(this.state, this.scene.getElementsIncludingDeleted());
  911. if (this.props.onChange) {
  912. this.props.onChange(this.scene.getElementsIncludingDeleted(), this.state);
  913. }
  914. }
  915. // Copy/paste
  916. private onCut = withBatchedUpdates((event: ClipboardEvent) => {
  917. if (isWritableElement(event.target)) {
  918. return;
  919. }
  920. this.copyAll();
  921. this.actionManager.executeAction(actionDeleteSelected);
  922. event.preventDefault();
  923. });
  924. private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
  925. if (isWritableElement(event.target)) {
  926. return;
  927. }
  928. this.copyAll();
  929. event.preventDefault();
  930. });
  931. private copyAll = () => {
  932. copyToClipboard(this.scene.getElements(), this.state);
  933. };
  934. private copyToClipboardAsPng = () => {
  935. const elements = this.scene.getElements();
  936. const selectedElements = getSelectedElements(elements, this.state);
  937. try {
  938. exportCanvas(
  939. "clipboard",
  940. selectedElements.length ? selectedElements : elements,
  941. this.state,
  942. this.canvas!,
  943. this.state,
  944. );
  945. } catch (error) {
  946. console.error(error);
  947. this.setState({ errorMessage: error.message });
  948. }
  949. };
  950. private copyToClipboardAsSvg = () => {
  951. const selectedElements = getSelectedElements(
  952. this.scene.getElements(),
  953. this.state,
  954. );
  955. try {
  956. exportCanvas(
  957. "clipboard-svg",
  958. selectedElements.length ? selectedElements : this.scene.getElements(),
  959. this.state,
  960. this.canvas!,
  961. this.state,
  962. );
  963. } catch (error) {
  964. console.error(error);
  965. this.setState({ errorMessage: error.message });
  966. }
  967. };
  968. private static resetTapTwice() {
  969. didTapTwice = false;
  970. }
  971. private onTapStart = (event: TouchEvent) => {
  972. if (!didTapTwice) {
  973. didTapTwice = true;
  974. clearTimeout(tappedTwiceTimer);
  975. tappedTwiceTimer = window.setTimeout(
  976. App.resetTapTwice,
  977. TAP_TWICE_TIMEOUT,
  978. );
  979. return;
  980. }
  981. // insert text only if we tapped twice with a single finger
  982. // event.touches.length === 1 will also prevent inserting text when user's zooming
  983. if (didTapTwice && event.touches.length === 1) {
  984. const [touch] = event.touches;
  985. // @ts-ignore
  986. this.handleCanvasDoubleClick({
  987. clientX: touch.clientX,
  988. clientY: touch.clientY,
  989. });
  990. didTapTwice = false;
  991. clearTimeout(tappedTwiceTimer);
  992. }
  993. event.preventDefault();
  994. if (event.touches.length === 2) {
  995. this.setState({
  996. selectedElementIds: {},
  997. });
  998. }
  999. };
  1000. private onTapEnd = (event: TouchEvent) => {
  1001. event.preventDefault();
  1002. if (event.touches.length > 0) {
  1003. this.setState({
  1004. previousSelectedElementIds: {},
  1005. selectedElementIds: this.state.previousSelectedElementIds,
  1006. });
  1007. }
  1008. };
  1009. private pasteFromClipboard = withBatchedUpdates(
  1010. async (event: ClipboardEvent | null) => {
  1011. // #686
  1012. const target = document.activeElement;
  1013. const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
  1014. if (
  1015. // if no ClipboardEvent supplied, assume we're pasting via contextMenu
  1016. // thus these checks don't make sense
  1017. event &&
  1018. (!(elementUnderCursor instanceof HTMLCanvasElement) ||
  1019. isWritableElement(target))
  1020. ) {
  1021. return;
  1022. }
  1023. const data = await parseClipboard(event);
  1024. if (data.errorMessage) {
  1025. this.setState({ errorMessage: data.errorMessage });
  1026. } else if (data.spreadsheet) {
  1027. this.addElementsFromPasteOrLibrary(
  1028. renderSpreadsheet(this.state, data.spreadsheet, cursorX, cursorY),
  1029. );
  1030. } else if (data.elements) {
  1031. this.addElementsFromPasteOrLibrary(data.elements);
  1032. } else if (data.text) {
  1033. this.addTextFromPaste(data.text);
  1034. }
  1035. this.selectShapeTool("selection");
  1036. event?.preventDefault();
  1037. },
  1038. );
  1039. private addElementsFromPasteOrLibrary = (
  1040. clipboardElements: readonly ExcalidrawElement[],
  1041. clientX = cursorX,
  1042. clientY = cursorY,
  1043. ) => {
  1044. const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
  1045. const elementsCenterX = distance(minX, maxX) / 2;
  1046. const elementsCenterY = distance(minY, maxY) / 2;
  1047. const { x, y } = viewportCoordsToSceneCoords(
  1048. { clientX, clientY },
  1049. this.state,
  1050. this.canvas,
  1051. window.devicePixelRatio,
  1052. );
  1053. const dx = x - elementsCenterX;
  1054. const dy = y - elementsCenterY;
  1055. const groupIdMap = new Map();
  1056. const oldIdToDuplicatedId = new Map();
  1057. const newElements = clipboardElements.map((element) => {
  1058. const [pastedPositionX, pastedPositionY] = getGridPoint(
  1059. element.x + dx - minX,
  1060. element.y + dy - minY,
  1061. this.state.gridSize,
  1062. );
  1063. const newElement = duplicateElement(
  1064. this.state.editingGroupId,
  1065. groupIdMap,
  1066. element,
  1067. {
  1068. x: pastedPositionX,
  1069. y: pastedPositionY,
  1070. },
  1071. );
  1072. oldIdToDuplicatedId.set(element.id, newElement.id);
  1073. return newElement;
  1074. });
  1075. const nextElements = [
  1076. ...this.scene.getElementsIncludingDeleted(),
  1077. ...newElements,
  1078. ];
  1079. fixBindingsAfterDuplication(
  1080. nextElements,
  1081. clipboardElements,
  1082. oldIdToDuplicatedId,
  1083. );
  1084. this.scene.replaceAllElements(nextElements);
  1085. history.resumeRecording();
  1086. this.setState(
  1087. selectGroupsForSelectedElements(
  1088. {
  1089. ...this.state,
  1090. isLibraryOpen: false,
  1091. selectedElementIds: newElements.reduce((map, element) => {
  1092. map[element.id] = true;
  1093. return map;
  1094. }, {} as any),
  1095. selectedGroupIds: {},
  1096. },
  1097. this.scene.getElements(),
  1098. ),
  1099. );
  1100. };
  1101. private addTextFromPaste(text: any) {
  1102. const { x, y } = viewportCoordsToSceneCoords(
  1103. { clientX: cursorX, clientY: cursorY },
  1104. this.state,
  1105. this.canvas,
  1106. window.devicePixelRatio,
  1107. );
  1108. const element = newTextElement({
  1109. x: x,
  1110. y: y,
  1111. strokeColor: this.state.currentItemStrokeColor,
  1112. backgroundColor: this.state.currentItemBackgroundColor,
  1113. fillStyle: this.state.currentItemFillStyle,
  1114. strokeWidth: this.state.currentItemStrokeWidth,
  1115. strokeStyle: this.state.currentItemStrokeStyle,
  1116. roughness: this.state.currentItemRoughness,
  1117. opacity: this.state.currentItemOpacity,
  1118. strokeSharpness: this.state.currentItemStrokeSharpness,
  1119. text: text,
  1120. fontSize: this.state.currentItemFontSize,
  1121. fontFamily: this.state.currentItemFontFamily,
  1122. textAlign: this.state.currentItemTextAlign,
  1123. verticalAlign: DEFAULT_VERTICAL_ALIGN,
  1124. });
  1125. this.scene.replaceAllElements([
  1126. ...this.scene.getElementsIncludingDeleted(),
  1127. element,
  1128. ]);
  1129. this.setState({ selectedElementIds: { [element.id]: true } });
  1130. history.resumeRecording();
  1131. }
  1132. // Collaboration
  1133. setAppState = (obj: any) => {
  1134. this.setState(obj);
  1135. };
  1136. removePointer = (event: React.PointerEvent<HTMLElement>) => {
  1137. // remove touch handler for context menu on touch devices
  1138. if (event.pointerType === "touch" && touchTimeout) {
  1139. clearTimeout(touchTimeout);
  1140. touchMoving = false;
  1141. }
  1142. gesture.pointers.delete(event.pointerId);
  1143. };
  1144. openPortal = async () => {
  1145. window.history.pushState(
  1146. {},
  1147. "Excalidraw",
  1148. await generateCollaborationLink(),
  1149. );
  1150. this.initializeSocketClient({ showLoadingState: false });
  1151. };
  1152. closePortal = () => {
  1153. this.saveCollabRoomToFirebase();
  1154. window.history.pushState({}, "Excalidraw", window.location.origin);
  1155. this.destroySocketClient();
  1156. };
  1157. toggleLock = () => {
  1158. this.setState((prevState) => ({
  1159. elementLocked: !prevState.elementLocked,
  1160. elementType: prevState.elementLocked
  1161. ? "selection"
  1162. : prevState.elementType,
  1163. }));
  1164. };
  1165. toggleZenMode = () => {
  1166. this.setState({
  1167. zenModeEnabled: !this.state.zenModeEnabled,
  1168. });
  1169. };
  1170. toggleGridMode = () => {
  1171. this.setState({
  1172. gridSize: this.state.gridSize ? null : GRID_SIZE,
  1173. });
  1174. };
  1175. setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => {
  1176. this.setState({
  1177. ...calculateScrollCenter(
  1178. getNonDeletedElements(remoteElements),
  1179. this.state,
  1180. this.canvas,
  1181. ),
  1182. });
  1183. };
  1184. private handleRemoteSceneUpdate = (
  1185. elements: readonly ExcalidrawElement[],
  1186. {
  1187. init = false,
  1188. initFromSnapshot = false,
  1189. }: { init?: boolean; initFromSnapshot?: boolean } = {},
  1190. ) => {
  1191. if (init) {
  1192. history.resumeRecording();
  1193. }
  1194. if (init || initFromSnapshot) {
  1195. this.setScrollToCenter(elements);
  1196. }
  1197. const newElements = this.portal.reconcileElements(elements);
  1198. // Avoid broadcasting to the rest of the collaborators the scene
  1199. // we just received!
  1200. // Note: this needs to be set before updating the scene as it
  1201. // syncronously calls render.
  1202. this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
  1203. this.updateScene({ elements: newElements });
  1204. // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
  1205. // when we receive any messages from another peer. This UX can be pretty rough -- if you
  1206. // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
  1207. // right now we think this is the right tradeoff.
  1208. this.resetHistory();
  1209. if (!this.portal.socketInitialized && !initFromSnapshot) {
  1210. this.initializeSocket();
  1211. }
  1212. };
  1213. private destroySocketClient = () => {
  1214. this.setState({
  1215. isCollaborating: false,
  1216. collaborators: new Map(),
  1217. });
  1218. this.portal.close();
  1219. };
  1220. public updateScene = withBatchedUpdates(
  1221. (sceneData: {
  1222. elements: readonly ExcalidrawElement[];
  1223. appState?: AppState;
  1224. }) => {
  1225. // currently we only support syncing background color
  1226. if (sceneData.appState?.viewBackgroundColor) {
  1227. this.setState({
  1228. viewBackgroundColor: sceneData.appState.viewBackgroundColor,
  1229. });
  1230. }
  1231. this.scene.replaceAllElements(sceneData.elements);
  1232. },
  1233. );
  1234. private initializeSocket = () => {
  1235. this.portal.socketInitialized = true;
  1236. clearTimeout(this.socketInitializationTimer);
  1237. if (this.state.isLoading && !this.unmounted) {
  1238. this.setState({ isLoading: false });
  1239. }
  1240. };
  1241. private initializeSocketClient = async (opts: {
  1242. showLoadingState: boolean;
  1243. clearScene?: boolean;
  1244. }) => {
  1245. if (this.portal.socket) {
  1246. return;
  1247. }
  1248. if (opts.clearScene) {
  1249. this.resetScene();
  1250. }
  1251. const roomMatch = getCollaborationLinkData(window.location.href);
  1252. if (roomMatch) {
  1253. const roomID = roomMatch[1];
  1254. const roomKey = roomMatch[2];
  1255. // fallback in case you're not alone in the room but still don't receive
  1256. // initial SCENE_UPDATE message
  1257. this.socketInitializationTimer = setTimeout(
  1258. this.initializeSocket,
  1259. INITIAL_SCENE_UPDATE_TIMEOUT,
  1260. );
  1261. const { default: socketIOClient }: any = await import(
  1262. /* webpackChunkName: "socketIoClient" */ "socket.io-client"
  1263. );
  1264. this.portal.open(socketIOClient(SOCKET_SERVER), roomID, roomKey);
  1265. // All socket listeners are moving to Portal
  1266. this.portal.socket!.on(
  1267. "client-broadcast",
  1268. async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
  1269. if (!this.portal.roomKey) {
  1270. return;
  1271. }
  1272. const decryptedData = await decryptAESGEM(
  1273. encryptedData,
  1274. this.portal.roomKey,
  1275. iv,
  1276. );
  1277. switch (decryptedData.type) {
  1278. case "INVALID_RESPONSE":
  1279. return;
  1280. case SCENE.INIT: {
  1281. if (!this.portal.socketInitialized) {
  1282. const remoteElements = decryptedData.payload.elements;
  1283. this.handleRemoteSceneUpdate(remoteElements, { init: true });
  1284. }
  1285. break;
  1286. }
  1287. case SCENE.UPDATE:
  1288. this.handleRemoteSceneUpdate(decryptedData.payload.elements);
  1289. break;
  1290. case "MOUSE_LOCATION": {
  1291. const {
  1292. pointer,
  1293. button,
  1294. username,
  1295. selectedElementIds,
  1296. } = decryptedData.payload;
  1297. const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
  1298. decryptedData.payload.socketId ||
  1299. // @ts-ignore legacy, see #2094 (#2097)
  1300. decryptedData.payload.socketID;
  1301. // NOTE purposefully mutating collaborators map in case of
  1302. // pointer updates so as not to trigger LayerUI rerender
  1303. this.setState((state) => {
  1304. if (!state.collaborators.has(socketId)) {
  1305. state.collaborators.set(socketId, {});
  1306. }
  1307. const user = state.collaborators.get(socketId)!;
  1308. user.pointer = pointer;
  1309. user.button = button;
  1310. user.selectedElementIds = selectedElementIds;
  1311. user.username = username;
  1312. state.collaborators.set(socketId, user);
  1313. return state;
  1314. });
  1315. break;
  1316. }
  1317. }
  1318. },
  1319. );
  1320. this.portal.socket!.on("first-in-room", () => {
  1321. if (this.portal.socket) {
  1322. this.portal.socket.off("first-in-room");
  1323. }
  1324. this.initializeSocket();
  1325. });
  1326. this.setState({
  1327. isCollaborating: true,
  1328. isLoading: opts.showLoadingState ? true : this.state.isLoading,
  1329. });
  1330. try {
  1331. const elements = await loadFromFirebase(roomID, roomKey);
  1332. if (elements) {
  1333. this.handleRemoteSceneUpdate(elements, { initFromSnapshot: true });
  1334. }
  1335. } catch (e) {
  1336. // log the error and move on. other peers will sync us the scene.
  1337. console.error(e);
  1338. }
  1339. }
  1340. };
  1341. // Portal-only
  1342. setCollaborators(sockets: string[]) {
  1343. this.setState((state) => {
  1344. const collaborators: typeof state.collaborators = new Map();
  1345. for (const socketId of sockets) {
  1346. if (state.collaborators.has(socketId)) {
  1347. collaborators.set(socketId, state.collaborators.get(socketId)!);
  1348. } else {
  1349. collaborators.set(socketId, {});
  1350. }
  1351. }
  1352. return {
  1353. ...state,
  1354. collaborators,
  1355. };
  1356. });
  1357. }
  1358. saveCollabRoomToFirebase = async (
  1359. syncableElements: ExcalidrawElement[] = getSyncableElements(
  1360. this.scene.getElementsIncludingDeleted(),
  1361. ),
  1362. ) => {
  1363. try {
  1364. await saveToFirebase(this.portal, syncableElements);
  1365. } catch (error) {
  1366. console.error(error);
  1367. }
  1368. };
  1369. private onSceneUpdated = () => {
  1370. this.setState({});
  1371. };
  1372. private updateCurrentCursorPosition = withBatchedUpdates(
  1373. (event: MouseEvent) => {
  1374. cursorX = event.x;
  1375. cursorY = event.y;
  1376. },
  1377. );
  1378. // Input handling
  1379. private onKeyDown = withBatchedUpdates((event: KeyboardEvent) => {
  1380. // ensures we don't prevent devTools select-element feature
  1381. if (event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "C") {
  1382. return;
  1383. }
  1384. if (
  1385. (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) ||
  1386. // case: using arrows to move between buttons
  1387. (isArrowKey(event.key) && isInputLike(event.target))
  1388. ) {
  1389. return;
  1390. }
  1391. if (event.key === KEYS.QUESTION_MARK) {
  1392. this.setState({
  1393. showShortcutsDialog: true,
  1394. });
  1395. }
  1396. if (
  1397. !event[KEYS.CTRL_OR_CMD] &&
  1398. event.altKey &&
  1399. event.keyCode === KEYS.Z_KEY_CODE
  1400. ) {
  1401. this.toggleZenMode();
  1402. }
  1403. if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) {
  1404. this.toggleGridMode();
  1405. }
  1406. if (event[KEYS.CTRL_OR_CMD]) {
  1407. this.setState({ isBindingEnabled: false });
  1408. }
  1409. if (event.code === "KeyC" && event.altKey && event.shiftKey) {
  1410. this.copyToClipboardAsPng();
  1411. event.preventDefault();
  1412. return;
  1413. }
  1414. if (this.actionManager.handleKeyDown(event)) {
  1415. return;
  1416. }
  1417. if (event.code === "Digit9") {
  1418. this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
  1419. }
  1420. if (isArrowKey(event.key)) {
  1421. const step =
  1422. (this.state.gridSize &&
  1423. (event.shiftKey ? ELEMENT_TRANSLATE_AMOUNT : this.state.gridSize)) ||
  1424. (event.shiftKey
  1425. ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
  1426. : ELEMENT_TRANSLATE_AMOUNT);
  1427. const selectedElements = this.scene
  1428. .getElements()
  1429. .filter((element) => this.state.selectedElementIds[element.id]);
  1430. let offsetX = 0;
  1431. let offsetY = 0;
  1432. if (event.key === KEYS.ARROW_LEFT) {
  1433. offsetX = -step;
  1434. } else if (event.key === KEYS.ARROW_RIGHT) {
  1435. offsetX = step;
  1436. } else if (event.key === KEYS.ARROW_UP) {
  1437. offsetY = -step;
  1438. } else if (event.key === KEYS.ARROW_DOWN) {
  1439. offsetY = step;
  1440. }
  1441. selectedElements.forEach((element) => {
  1442. mutateElement(element, {
  1443. x: element.x + offsetX,
  1444. y: element.y + offsetY,
  1445. });
  1446. updateBoundElements(element, {
  1447. simultaneouslyUpdated: selectedElements,
  1448. });
  1449. });
  1450. this.maybeSuggestBindingForAll(selectedElements);
  1451. event.preventDefault();
  1452. } else if (event.key === KEYS.ENTER) {
  1453. const selectedElements = getSelectedElements(
  1454. this.scene.getElements(),
  1455. this.state,
  1456. );
  1457. if (
  1458. selectedElements.length === 1 &&
  1459. isLinearElement(selectedElements[0])
  1460. ) {
  1461. if (
  1462. !this.state.editingLinearElement ||
  1463. this.state.editingLinearElement.elementId !== selectedElements[0].id
  1464. ) {
  1465. history.resumeRecording();
  1466. this.setState({
  1467. editingLinearElement: new LinearElementEditor(
  1468. selectedElements[0],
  1469. this.scene,
  1470. ),
  1471. });
  1472. }
  1473. } else if (
  1474. selectedElements.length === 1 &&
  1475. !isLinearElement(selectedElements[0])
  1476. ) {
  1477. const selectedElement = selectedElements[0];
  1478. this.startTextEditing({
  1479. sceneX: selectedElement.x + selectedElement.width / 2,
  1480. sceneY: selectedElement.y + selectedElement.height / 2,
  1481. });
  1482. event.preventDefault();
  1483. return;
  1484. }
  1485. } else if (
  1486. !event.ctrlKey &&
  1487. !event.altKey &&
  1488. !event.metaKey &&
  1489. this.state.draggingElement === null
  1490. ) {
  1491. const shape = findShapeByKey(event.key);
  1492. if (shape) {
  1493. this.selectShapeTool(shape);
  1494. } else if (event.key === "q") {
  1495. this.toggleLock();
  1496. }
  1497. }
  1498. if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
  1499. isHoldingSpace = true;
  1500. document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
  1501. }
  1502. });
  1503. private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
  1504. if (event.key === KEYS.SPACE) {
  1505. if (this.state.elementType === "selection") {
  1506. resetCursor();
  1507. } else {
  1508. setCursorForShape(this.state.elementType);
  1509. this.setState({
  1510. selectedElementIds: {},
  1511. selectedGroupIds: {},
  1512. editingGroupId: null,
  1513. });
  1514. }
  1515. isHoldingSpace = false;
  1516. }
  1517. if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
  1518. this.setState({ isBindingEnabled: true });
  1519. }
  1520. if (isArrowKey(event.key)) {
  1521. const selectedElements = getSelectedElements(
  1522. this.scene.getElements(),
  1523. this.state,
  1524. );
  1525. isBindingEnabled(this.state)
  1526. ? bindOrUnbindSelectedElements(selectedElements)
  1527. : unbindLinearElements(selectedElements);
  1528. this.setState({ suggestedBindings: [] });
  1529. }
  1530. });
  1531. private selectShapeTool(elementType: AppState["elementType"]) {
  1532. if (!isHoldingSpace) {
  1533. setCursorForShape(elementType);
  1534. }
  1535. if (isToolIcon(document.activeElement)) {
  1536. document.activeElement.blur();
  1537. }
  1538. if (!isLinearElementType(elementType)) {
  1539. this.setState({ suggestedBindings: [] });
  1540. }
  1541. if (elementType !== "selection") {
  1542. this.setState({
  1543. elementType,
  1544. selectedElementIds: {},
  1545. selectedGroupIds: {},
  1546. editingGroupId: null,
  1547. });
  1548. } else {
  1549. this.setState({ elementType });
  1550. }
  1551. }
  1552. private onGestureStart = withBatchedUpdates((event: GestureEvent) => {
  1553. event.preventDefault();
  1554. this.setState({
  1555. selectedElementIds: {},
  1556. });
  1557. gesture.initialScale = this.state.zoom;
  1558. });
  1559. private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
  1560. event.preventDefault();
  1561. this.setState({
  1562. zoom: getNormalizedZoom(gesture.initialScale! * event.scale),
  1563. });
  1564. });
  1565. private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
  1566. event.preventDefault();
  1567. this.setState({
  1568. previousSelectedElementIds: {},
  1569. selectedElementIds: this.state.previousSelectedElementIds,
  1570. });
  1571. gesture.initialScale = null;
  1572. });
  1573. private handleTextWysiwyg(
  1574. element: ExcalidrawTextElement,
  1575. {
  1576. isExistingElement = false,
  1577. }: {
  1578. isExistingElement?: boolean;
  1579. },
  1580. ) {
  1581. const updateElement = (text: string, isDeleted = false) => {
  1582. this.scene.replaceAllElements([
  1583. ...this.scene.getElementsIncludingDeleted().map((_element) => {
  1584. if (_element.id === element.id && isTextElement(_element)) {
  1585. return updateTextElement(_element, {
  1586. text,
  1587. isDeleted,
  1588. });
  1589. }
  1590. return _element;
  1591. }),
  1592. ]);
  1593. };
  1594. textWysiwyg({
  1595. id: element.id,
  1596. appState: this.state,
  1597. getViewportCoords: (x, y) => {
  1598. const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
  1599. {
  1600. sceneX: x,
  1601. sceneY: y,
  1602. },
  1603. this.state,
  1604. this.canvas,
  1605. window.devicePixelRatio,
  1606. );
  1607. return [viewportX, viewportY];
  1608. },
  1609. onChange: withBatchedUpdates((text) => {
  1610. updateElement(text);
  1611. if (isNonDeletedElement(element)) {
  1612. updateBoundElements(element);
  1613. }
  1614. }),
  1615. onSubmit: withBatchedUpdates((text) => {
  1616. const isDeleted = !text.trim();
  1617. updateElement(text, isDeleted);
  1618. if (!isDeleted) {
  1619. this.setState((prevState) => ({
  1620. selectedElementIds: {
  1621. ...prevState.selectedElementIds,
  1622. [element.id]: true,
  1623. },
  1624. }));
  1625. } else {
  1626. fixBindingsAfterDeletion(this.scene.getElements(), [element]);
  1627. }
  1628. if (!isDeleted || isExistingElement) {
  1629. history.resumeRecording();
  1630. }
  1631. this.setState({
  1632. draggingElement: null,
  1633. editingElement: null,
  1634. });
  1635. if (this.state.elementLocked) {
  1636. setCursorForShape(this.state.elementType);
  1637. }
  1638. }),
  1639. element,
  1640. });
  1641. // deselect all other elements when inserting text
  1642. this.setState({
  1643. selectedElementIds: {},
  1644. selectedGroupIds: {},
  1645. editingGroupId: null,
  1646. });
  1647. // do an initial update to re-initialize element position since we were
  1648. // modifying element's x/y for sake of editor (case: syncing to remote)
  1649. updateElement(element.text);
  1650. }
  1651. private getTextElementAtPosition(
  1652. x: number,
  1653. y: number,
  1654. ): NonDeleted<ExcalidrawTextElement> | null {
  1655. const element = this.getElementAtPosition(x, y);
  1656. if (element && isTextElement(element) && !element.isDeleted) {
  1657. return element;
  1658. }
  1659. return null;
  1660. }
  1661. private getElementAtPosition(
  1662. x: number,
  1663. y: number,
  1664. ): NonDeleted<ExcalidrawElement> | null {
  1665. const allHitElements = this.getElementsAtPosition(x, y);
  1666. if (allHitElements.length > 1) {
  1667. const elementWithHighestZIndex =
  1668. allHitElements[allHitElements.length - 1];
  1669. // If we're hitting element with highest z-index only on its bounding box
  1670. // while also hitting other element figure, the latter should be considered.
  1671. return isHittingElementBoundingBoxWithoutHittingElement(
  1672. elementWithHighestZIndex,
  1673. this.state,
  1674. x,
  1675. y,
  1676. )
  1677. ? allHitElements[allHitElements.length - 2]
  1678. : elementWithHighestZIndex;
  1679. }
  1680. if (allHitElements.length === 1) {
  1681. return allHitElements[0];
  1682. }
  1683. return null;
  1684. }
  1685. private getElementsAtPosition(
  1686. x: number,
  1687. y: number,
  1688. ): NonDeleted<ExcalidrawElement>[] {
  1689. return getElementsAtPosition(this.scene.getElements(), (element) =>
  1690. hitTest(element, this.state, x, y),
  1691. );
  1692. }
  1693. private startTextEditing = ({
  1694. sceneX,
  1695. sceneY,
  1696. insertAtParentCenter = true,
  1697. }: {
  1698. /** X position to insert text at */
  1699. sceneX: number;
  1700. /** Y position to insert text at */
  1701. sceneY: number;
  1702. /** whether to attempt to insert at element center if applicable */
  1703. insertAtParentCenter?: boolean;
  1704. }) => {
  1705. const existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
  1706. const parentCenterPosition =
  1707. insertAtParentCenter &&
  1708. this.getTextWysiwygSnappedToCenterPosition(
  1709. sceneX,
  1710. sceneY,
  1711. this.state,
  1712. this.canvas,
  1713. window.devicePixelRatio,
  1714. );
  1715. const element = existingTextElement
  1716. ? existingTextElement
  1717. : newTextElement({
  1718. x: parentCenterPosition
  1719. ? parentCenterPosition.elementCenterX
  1720. : sceneX,
  1721. y: parentCenterPosition
  1722. ? parentCenterPosition.elementCenterY
  1723. : sceneY,
  1724. strokeColor: this.state.currentItemStrokeColor,
  1725. backgroundColor: this.state.currentItemBackgroundColor,
  1726. fillStyle: this.state.currentItemFillStyle,
  1727. strokeWidth: this.state.currentItemStrokeWidth,
  1728. strokeStyle: this.state.currentItemStrokeStyle,
  1729. roughness: this.state.currentItemRoughness,
  1730. opacity: this.state.currentItemOpacity,
  1731. strokeSharpness: this.state.currentItemStrokeSharpness,
  1732. text: "",
  1733. fontSize: this.state.currentItemFontSize,
  1734. fontFamily: this.state.currentItemFontFamily,
  1735. textAlign: parentCenterPosition
  1736. ? "center"
  1737. : this.state.currentItemTextAlign,
  1738. verticalAlign: parentCenterPosition
  1739. ? "middle"
  1740. : DEFAULT_VERTICAL_ALIGN,
  1741. });
  1742. this.setState({ editingElement: element });
  1743. if (existingTextElement) {
  1744. // if text element is no longer centered to a container, reset
  1745. // verticalAlign to default because it's currently internal-only
  1746. if (!parentCenterPosition || element.textAlign !== "center") {
  1747. mutateElement(element, { verticalAlign: DEFAULT_VERTICAL_ALIGN });
  1748. }
  1749. } else {
  1750. this.scene.replaceAllElements([
  1751. ...this.scene.getElementsIncludingDeleted(),
  1752. element,
  1753. ]);
  1754. // case: creating new text not centered to parent elemenent → offset Y
  1755. // so that the text is centered to cursor position
  1756. if (!parentCenterPosition) {
  1757. mutateElement(element, {
  1758. y: element.y - element.baseline / 2,
  1759. });
  1760. }
  1761. }
  1762. this.setState({
  1763. editingElement: element,
  1764. });
  1765. this.handleTextWysiwyg(element, {
  1766. isExistingElement: !!existingTextElement,
  1767. });
  1768. };
  1769. private handleCanvasDoubleClick = (
  1770. event: React.MouseEvent<HTMLCanvasElement>,
  1771. ) => {
  1772. // case: double-clicking with arrow/line tool selected would both create
  1773. // text and enter multiElement mode
  1774. if (this.state.multiElement) {
  1775. return;
  1776. }
  1777. // we should only be able to double click when mode is selection
  1778. if (this.state.elementType !== "selection") {
  1779. return;
  1780. }
  1781. const selectedElements = getSelectedElements(
  1782. this.scene.getElements(),
  1783. this.state,
  1784. );
  1785. if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
  1786. if (
  1787. !this.state.editingLinearElement ||
  1788. this.state.editingLinearElement.elementId !== selectedElements[0].id
  1789. ) {
  1790. history.resumeRecording();
  1791. this.setState({
  1792. editingLinearElement: new LinearElementEditor(
  1793. selectedElements[0],
  1794. this.scene,
  1795. ),
  1796. });
  1797. }
  1798. return;
  1799. }
  1800. resetCursor();
  1801. const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
  1802. event,
  1803. this.state,
  1804. this.canvas,
  1805. window.devicePixelRatio,
  1806. );
  1807. const selectedGroupIds = getSelectedGroupIds(this.state);
  1808. if (selectedGroupIds.length > 0) {
  1809. const hitElement = this.getElementAtPosition(sceneX, sceneY);
  1810. const selectedGroupId =
  1811. hitElement &&
  1812. getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
  1813. if (selectedGroupId) {
  1814. this.setState((prevState) =>
  1815. selectGroupsForSelectedElements(
  1816. {
  1817. ...prevState,
  1818. editingGroupId: selectedGroupId,
  1819. selectedElementIds: { [hitElement!.id]: true },
  1820. selectedGroupIds: {},
  1821. },
  1822. this.scene.getElements(),
  1823. ),
  1824. );
  1825. return;
  1826. }
  1827. }
  1828. resetCursor();
  1829. if (!event[KEYS.CTRL_OR_CMD]) {
  1830. this.startTextEditing({
  1831. sceneX,
  1832. sceneY,
  1833. insertAtParentCenter: !event.altKey,
  1834. });
  1835. }
  1836. };
  1837. private handleCanvasPointerMove = (
  1838. event: React.PointerEvent<HTMLCanvasElement>,
  1839. ) => {
  1840. this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
  1841. if (gesture.pointers.has(event.pointerId)) {
  1842. gesture.pointers.set(event.pointerId, {
  1843. x: event.clientX,
  1844. y: event.clientY,
  1845. });
  1846. }
  1847. if (gesture.pointers.size === 2) {
  1848. const center = getCenter(gesture.pointers);
  1849. const deltaX = center.x - gesture.lastCenter!.x;
  1850. const deltaY = center.y - gesture.lastCenter!.y;
  1851. gesture.lastCenter = center;
  1852. const distance = getDistance(Array.from(gesture.pointers.values()));
  1853. const scaleFactor = distance / gesture.initialDistance!;
  1854. this.setState({
  1855. scrollX: normalizeScroll(this.state.scrollX + deltaX / this.state.zoom),
  1856. scrollY: normalizeScroll(this.state.scrollY + deltaY / this.state.zoom),
  1857. zoom: getNormalizedZoom(gesture.initialScale! * scaleFactor),
  1858. shouldCacheIgnoreZoom: true,
  1859. });
  1860. this.resetShouldCacheIgnoreZoomDebounced();
  1861. } else {
  1862. gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null;
  1863. }
  1864. if (isHoldingSpace || isPanning || isDraggingScrollBar) {
  1865. return;
  1866. }
  1867. const isPointerOverScrollBars = isOverScrollBars(
  1868. currentScrollBars,
  1869. event.clientX,
  1870. event.clientY,
  1871. );
  1872. const isOverScrollBar = isPointerOverScrollBars.isOverEither;
  1873. if (!this.state.draggingElement && !this.state.multiElement) {
  1874. if (isOverScrollBar) {
  1875. resetCursor();
  1876. } else {
  1877. setCursorForShape(this.state.elementType);
  1878. }
  1879. }
  1880. const scenePointer = viewportCoordsToSceneCoords(
  1881. event,
  1882. this.state,
  1883. this.canvas,
  1884. window.devicePixelRatio,
  1885. );
  1886. const { x: scenePointerX, y: scenePointerY } = scenePointer;
  1887. if (
  1888. this.state.editingLinearElement &&
  1889. !this.state.editingLinearElement.isDragging
  1890. ) {
  1891. const editingLinearElement = LinearElementEditor.handlePointerMove(
  1892. event,
  1893. scenePointerX,
  1894. scenePointerY,
  1895. this.state.editingLinearElement,
  1896. this.state.gridSize,
  1897. );
  1898. if (editingLinearElement !== this.state.editingLinearElement) {
  1899. this.setState({ editingLinearElement });
  1900. }
  1901. if (editingLinearElement.lastUncommittedPoint != null) {
  1902. this.maybeSuggestBindingAtCursor(scenePointer);
  1903. } else {
  1904. this.setState({ suggestedBindings: [] });
  1905. }
  1906. }
  1907. if (isBindingElementType(this.state.elementType)) {
  1908. // Hovering with a selected tool or creating new linear element via click
  1909. // and point
  1910. const { draggingElement } = this.state;
  1911. if (isBindingElement(draggingElement)) {
  1912. this.maybeSuggestBindingForLinearElementAtCursor(
  1913. draggingElement,
  1914. "end",
  1915. scenePointer,
  1916. this.state.startBoundElement,
  1917. );
  1918. } else {
  1919. this.maybeSuggestBindingAtCursor(scenePointer);
  1920. }
  1921. }
  1922. if (this.state.multiElement) {
  1923. const { multiElement } = this.state;
  1924. const { x: rx, y: ry } = multiElement;
  1925. const { points, lastCommittedPoint } = multiElement;
  1926. const lastPoint = points[points.length - 1];
  1927. setCursorForShape(this.state.elementType);
  1928. if (lastPoint === lastCommittedPoint) {
  1929. // if we haven't yet created a temp point and we're beyond commit-zone
  1930. // threshold, add a point
  1931. if (
  1932. distance2d(
  1933. scenePointerX - rx,
  1934. scenePointerY - ry,
  1935. lastPoint[0],
  1936. lastPoint[1],
  1937. ) >= LINE_CONFIRM_THRESHOLD
  1938. ) {
  1939. mutateElement(multiElement, {
  1940. points: [...points, [scenePointerX - rx, scenePointerY - ry]],
  1941. });
  1942. } else {
  1943. document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
  1944. // in this branch, we're inside the commit zone, and no uncommitted
  1945. // point exists. Thus do nothing (don't add/remove points).
  1946. }
  1947. } else {
  1948. // cursor moved inside commit zone, and there's uncommitted point,
  1949. // thus remove it
  1950. if (
  1951. points.length > 2 &&
  1952. lastCommittedPoint &&
  1953. distance2d(
  1954. scenePointerX - rx,
  1955. scenePointerY - ry,
  1956. lastCommittedPoint[0],
  1957. lastCommittedPoint[1],
  1958. ) < LINE_CONFIRM_THRESHOLD
  1959. ) {
  1960. document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
  1961. mutateElement(multiElement, {
  1962. points: points.slice(0, -1),
  1963. });
  1964. } else {
  1965. if (isPathALoop(points)) {
  1966. document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
  1967. }
  1968. // update last uncommitted point
  1969. mutateElement(multiElement, {
  1970. points: [
  1971. ...points.slice(0, -1),
  1972. [scenePointerX - rx, scenePointerY - ry],
  1973. ],
  1974. });
  1975. }
  1976. }
  1977. return;
  1978. }
  1979. const hasDeselectedButton = Boolean(event.buttons);
  1980. if (
  1981. hasDeselectedButton ||
  1982. (this.state.elementType !== "selection" &&
  1983. this.state.elementType !== "text")
  1984. ) {
  1985. return;
  1986. }
  1987. const elements = this.scene.getElements();
  1988. const selectedElements = getSelectedElements(elements, this.state);
  1989. if (
  1990. selectedElements.length === 1 &&
  1991. !isOverScrollBar &&
  1992. !this.state.editingLinearElement
  1993. ) {
  1994. const elementWithTransformHandleType = getElementWithTransformHandleType(
  1995. elements,
  1996. this.state,
  1997. scenePointerX,
  1998. scenePointerY,
  1999. this.state.zoom,
  2000. event.pointerType,
  2001. );
  2002. if (
  2003. elementWithTransformHandleType &&
  2004. elementWithTransformHandleType.transformHandleType
  2005. ) {
  2006. document.documentElement.style.cursor = getCursorForResizingElement(
  2007. elementWithTransformHandleType,
  2008. );
  2009. return;
  2010. }
  2011. } else if (selectedElements.length > 1 && !isOverScrollBar) {
  2012. const transformHandleType = getTransformHandleTypeFromCoords(
  2013. getCommonBounds(selectedElements),
  2014. scenePointerX,
  2015. scenePointerY,
  2016. this.state.zoom,
  2017. event.pointerType,
  2018. );
  2019. if (transformHandleType) {
  2020. document.documentElement.style.cursor = getCursorForResizingElement({
  2021. transformHandleType,
  2022. });
  2023. return;
  2024. }
  2025. }
  2026. const hitElement = this.getElementAtPosition(
  2027. scenePointer.x,
  2028. scenePointer.y,
  2029. );
  2030. if (this.state.elementType === "text") {
  2031. document.documentElement.style.cursor = isTextElement(hitElement)
  2032. ? CURSOR_TYPE.TEXT
  2033. : CURSOR_TYPE.CROSSHAIR;
  2034. } else if (isOverScrollBar) {
  2035. document.documentElement.style.cursor = CURSOR_TYPE.AUTO;
  2036. } else if (
  2037. hitElement ||
  2038. this.isHittingCommonBoundingBoxOfSelectedElements(
  2039. scenePointer,
  2040. selectedElements,
  2041. )
  2042. ) {
  2043. document.documentElement.style.cursor = CURSOR_TYPE.MOVE;
  2044. } else {
  2045. document.documentElement.style.cursor = CURSOR_TYPE.AUTO;
  2046. }
  2047. };
  2048. // set touch moving for mobile context menu
  2049. private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
  2050. touchMoving = true;
  2051. };
  2052. private handleCanvasPointerDown = (
  2053. event: React.PointerEvent<HTMLCanvasElement>,
  2054. ) => {
  2055. event.persist();
  2056. this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
  2057. this.maybeCleanupAfterMissingPointerUp(event);
  2058. if (isPanning) {
  2059. return;
  2060. }
  2061. this.setState({
  2062. lastPointerDownWith: event.pointerType,
  2063. cursorButton: "down",
  2064. });
  2065. this.savePointer(event.clientX, event.clientY, "down");
  2066. if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
  2067. return;
  2068. }
  2069. // only handle left mouse button or touch
  2070. if (
  2071. event.button !== POINTER_BUTTON.MAIN &&
  2072. event.button !== POINTER_BUTTON.TOUCH
  2073. ) {
  2074. return;
  2075. }
  2076. this.updateGestureOnPointerDown(event);
  2077. // fixes pointermove causing selection of UI texts #32
  2078. event.preventDefault();
  2079. // Preventing the event above disables default behavior
  2080. // of defocusing potentially focused element, which is what we
  2081. // want when clicking inside the canvas.
  2082. if (document.activeElement instanceof HTMLElement) {
  2083. document.activeElement.blur();
  2084. }
  2085. // don't select while panning
  2086. if (gesture.pointers.size > 1) {
  2087. return;
  2088. }
  2089. // State for the duration of a pointer interaction, which starts with a
  2090. // pointerDown event, ends with a pointerUp event (or another pointerDown)
  2091. const pointerDownState = this.initialPointerDownState(event);
  2092. if (this.handleDraggingScrollBar(event, pointerDownState)) {
  2093. return;
  2094. }
  2095. this.clearSelectionIfNotUsingSelection();
  2096. this.updateBindingEnabledOnPointerMove(event);
  2097. if (this.handleSelectionOnPointerDown(event, pointerDownState)) {
  2098. return;
  2099. }
  2100. if (this.state.elementType === "text") {
  2101. this.handleTextOnPointerDown(event, pointerDownState);
  2102. return;
  2103. } else if (
  2104. this.state.elementType === "arrow" ||
  2105. this.state.elementType === "draw" ||
  2106. this.state.elementType === "line"
  2107. ) {
  2108. this.handleLinearElementOnPointerDown(
  2109. event,
  2110. this.state.elementType,
  2111. pointerDownState,
  2112. );
  2113. } else {
  2114. this.createGenericElementOnPointerDown(
  2115. this.state.elementType,
  2116. pointerDownState,
  2117. );
  2118. }
  2119. const onPointerMove = this.onPointerMoveFromPointerDownHandler(
  2120. pointerDownState,
  2121. );
  2122. const onPointerUp = this.onPointerUpFromPointerDownHandler(
  2123. pointerDownState,
  2124. );
  2125. lastPointerUp = onPointerUp;
  2126. window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
  2127. window.addEventListener(EVENT.POINTER_UP, onPointerUp);
  2128. pointerDownState.eventListeners.onMove = onPointerMove;
  2129. pointerDownState.eventListeners.onUp = onPointerUp;
  2130. };
  2131. private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
  2132. event: React.PointerEvent<HTMLCanvasElement>,
  2133. ): void => {
  2134. // deal with opening context menu on touch devices
  2135. if (event.pointerType === "touch") {
  2136. touchMoving = false;
  2137. // open the context menu with the first touch's clientX and clientY
  2138. // if the touch is not moving
  2139. touchTimeout = window.setTimeout(() => {
  2140. if (!touchMoving) {
  2141. this.openContextMenu({
  2142. clientX: event.clientX,
  2143. clientY: event.clientY,
  2144. });
  2145. }
  2146. }, TOUCH_CTX_MENU_TIMEOUT);
  2147. }
  2148. };
  2149. private maybeCleanupAfterMissingPointerUp(
  2150. event: React.PointerEvent<HTMLCanvasElement>,
  2151. ): void {
  2152. if (lastPointerUp !== null) {
  2153. // Unfortunately, sometimes we don't get a pointerup after a pointerdown,
  2154. // this can happen when a contextual menu or alert is triggered. In order to avoid
  2155. // being in a weird state, we clean up on the next pointerdown
  2156. lastPointerUp(event);
  2157. }
  2158. }
  2159. // Returns whether the event is a panning
  2160. private handleCanvasPanUsingWheelOrSpaceDrag = (
  2161. event: React.PointerEvent<HTMLCanvasElement>,
  2162. ): boolean => {
  2163. if (
  2164. !(
  2165. gesture.pointers.size === 0 &&
  2166. (event.button === POINTER_BUTTON.WHEEL ||
  2167. (event.button === POINTER_BUTTON.MAIN && isHoldingSpace))
  2168. )
  2169. ) {
  2170. return false;
  2171. }
  2172. isPanning = true;
  2173. let nextPastePrevented = false;
  2174. const isLinux = /Linux/.test(window.navigator.platform);
  2175. document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
  2176. let { clientX: lastX, clientY: lastY } = event;
  2177. const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
  2178. const deltaX = lastX - event.clientX;
  2179. const deltaY = lastY - event.clientY;
  2180. lastX = event.clientX;
  2181. lastY = event.clientY;
  2182. /*
  2183. * Prevent paste event if we move while middle clicking on Linux.
  2184. * See issue #1383.
  2185. */
  2186. if (
  2187. isLinux &&
  2188. !nextPastePrevented &&
  2189. (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)
  2190. ) {
  2191. nextPastePrevented = true;
  2192. /* Prevent the next paste event */
  2193. const preventNextPaste = (event: ClipboardEvent) => {
  2194. document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
  2195. event.stopPropagation();
  2196. };
  2197. /*
  2198. * Reenable next paste in case of disabled middle click paste for
  2199. * any reason:
  2200. * - rigth click paste
  2201. * - empty clipboard
  2202. */
  2203. const enableNextPaste = () => {
  2204. setTimeout(() => {
  2205. document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
  2206. window.removeEventListener(EVENT.POINTER_UP, enableNextPaste);
  2207. }, 100);
  2208. };
  2209. document.body.addEventListener(EVENT.PASTE, preventNextPaste);
  2210. window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
  2211. }
  2212. this.setState({
  2213. scrollX: normalizeScroll(this.state.scrollX - deltaX / this.state.zoom),
  2214. scrollY: normalizeScroll(this.state.scrollY - deltaY / this.state.zoom),
  2215. });
  2216. });
  2217. const teardown = withBatchedUpdates(
  2218. (lastPointerUp = () => {
  2219. lastPointerUp = null;
  2220. isPanning = false;
  2221. if (!isHoldingSpace) {
  2222. setCursorForShape(this.state.elementType);
  2223. }
  2224. this.setState({
  2225. cursorButton: "up",
  2226. });
  2227. this.savePointer(event.clientX, event.clientY, "up");
  2228. window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
  2229. window.removeEventListener(EVENT.POINTER_UP, teardown);
  2230. window.removeEventListener(EVENT.BLUR, teardown);
  2231. }),
  2232. );
  2233. window.addEventListener(EVENT.BLUR, teardown);
  2234. window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, {
  2235. passive: true,
  2236. });
  2237. window.addEventListener(EVENT.POINTER_UP, teardown);
  2238. return true;
  2239. };
  2240. private updateGestureOnPointerDown(
  2241. event: React.PointerEvent<HTMLCanvasElement>,
  2242. ): void {
  2243. gesture.pointers.set(event.pointerId, {
  2244. x: event.clientX,
  2245. y: event.clientY,
  2246. });
  2247. if (gesture.pointers.size === 2) {
  2248. gesture.lastCenter = getCenter(gesture.pointers);
  2249. gesture.initialScale = this.state.zoom;
  2250. gesture.initialDistance = getDistance(
  2251. Array.from(gesture.pointers.values()),
  2252. );
  2253. }
  2254. }
  2255. private initialPointerDownState(
  2256. event: React.PointerEvent<HTMLCanvasElement>,
  2257. ): PointerDownState {
  2258. const origin = viewportCoordsToSceneCoords(
  2259. event,
  2260. this.state,
  2261. this.canvas,
  2262. window.devicePixelRatio,
  2263. );
  2264. const selectedElements = getSelectedElements(
  2265. this.scene.getElements(),
  2266. this.state,
  2267. );
  2268. const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
  2269. return {
  2270. origin,
  2271. originInGrid: tupleToCoors(
  2272. getGridPoint(origin.x, origin.y, this.state.gridSize),
  2273. ),
  2274. scrollbars: isOverScrollBars(
  2275. currentScrollBars,
  2276. event.clientX,
  2277. event.clientY,
  2278. ),
  2279. // we need to duplicate because we'll be updating this state
  2280. lastCoords: { ...origin },
  2281. originalElements: this.scene.getElements().reduce((acc, element) => {
  2282. acc.set(element.id, {
  2283. x: element.x,
  2284. y: element.y,
  2285. angle: element.angle,
  2286. });
  2287. return acc;
  2288. }, new Map() as PointerDownState["originalElements"]),
  2289. resize: {
  2290. handleType: false,
  2291. isResizing: false,
  2292. offset: { x: 0, y: 0 },
  2293. arrowDirection: "origin",
  2294. center: { x: (maxX + minX) / 2, y: (maxY + minY) / 2 },
  2295. },
  2296. hit: {
  2297. element: null,
  2298. allHitElements: [],
  2299. wasAddedToSelection: false,
  2300. hasBeenDuplicated: false,
  2301. hasHitCommonBoundingBoxOfSelectedElements: this.isHittingCommonBoundingBoxOfSelectedElements(
  2302. origin,
  2303. selectedElements,
  2304. ),
  2305. },
  2306. drag: {
  2307. hasOccurred: false,
  2308. offset: null,
  2309. },
  2310. eventListeners: {
  2311. onMove: null,
  2312. onUp: null,
  2313. },
  2314. };
  2315. }
  2316. // Returns whether the event is a dragging a scrollbar
  2317. private handleDraggingScrollBar(
  2318. event: React.PointerEvent<HTMLCanvasElement>,
  2319. pointerDownState: PointerDownState,
  2320. ): boolean {
  2321. if (
  2322. !(pointerDownState.scrollbars.isOverEither && !this.state.multiElement)
  2323. ) {
  2324. return false;
  2325. }
  2326. isDraggingScrollBar = true;
  2327. pointerDownState.lastCoords.x = event.clientX;
  2328. pointerDownState.lastCoords.y = event.clientY;
  2329. const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
  2330. const target = event.target;
  2331. if (!(target instanceof HTMLElement)) {
  2332. return;
  2333. }
  2334. this.handlePointerMoveOverScrollbars(event, pointerDownState);
  2335. });
  2336. const onPointerUp = withBatchedUpdates(() => {
  2337. isDraggingScrollBar = false;
  2338. setCursorForShape(this.state.elementType);
  2339. lastPointerUp = null;
  2340. this.setState({
  2341. cursorButton: "up",
  2342. });
  2343. this.savePointer(event.clientX, event.clientY, "up");
  2344. window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
  2345. window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
  2346. });
  2347. lastPointerUp = onPointerUp;
  2348. window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
  2349. window.addEventListener(EVENT.POINTER_UP, onPointerUp);
  2350. return true;
  2351. }
  2352. private clearSelectionIfNotUsingSelection = (): void => {
  2353. if (this.state.elementType !== "selection") {
  2354. this.setState({
  2355. selectedElementIds: {},
  2356. selectedGroupIds: {},
  2357. editingGroupId: null,
  2358. });
  2359. }
  2360. };
  2361. /**
  2362. * @returns whether the pointer event has been completely handled
  2363. */
  2364. private handleSelectionOnPointerDown = (
  2365. event: React.PointerEvent<HTMLCanvasElement>,
  2366. pointerDownState: PointerDownState,
  2367. ): boolean => {
  2368. if (this.state.elementType === "selection") {
  2369. const elements = this.scene.getElements();
  2370. const selectedElements = getSelectedElements(elements, this.state);
  2371. if (selectedElements.length === 1 && !this.state.editingLinearElement) {
  2372. const elementWithTransformHandleType = getElementWithTransformHandleType(
  2373. elements,
  2374. this.state,
  2375. pointerDownState.origin.x,
  2376. pointerDownState.origin.y,
  2377. this.state.zoom,
  2378. event.pointerType,
  2379. );
  2380. if (elementWithTransformHandleType != null) {
  2381. this.setState({
  2382. resizingElement: elementWithTransformHandleType.element,
  2383. });
  2384. pointerDownState.resize.handleType =
  2385. elementWithTransformHandleType.transformHandleType;
  2386. }
  2387. } else if (selectedElements.length > 1) {
  2388. pointerDownState.resize.handleType = getTransformHandleTypeFromCoords(
  2389. getCommonBounds(selectedElements),
  2390. pointerDownState.origin.x,
  2391. pointerDownState.origin.y,
  2392. this.state.zoom,
  2393. event.pointerType,
  2394. );
  2395. }
  2396. if (pointerDownState.resize.handleType) {
  2397. document.documentElement.style.cursor = getCursorForResizingElement({
  2398. transformHandleType: pointerDownState.resize.handleType,
  2399. });
  2400. pointerDownState.resize.isResizing = true;
  2401. pointerDownState.resize.offset = tupleToCoors(
  2402. getResizeOffsetXY(
  2403. pointerDownState.resize.handleType,
  2404. selectedElements,
  2405. pointerDownState.origin.x,
  2406. pointerDownState.origin.y,
  2407. ),
  2408. );
  2409. if (
  2410. selectedElements.length === 1 &&
  2411. isLinearElement(selectedElements[0]) &&
  2412. selectedElements[0].points.length === 2
  2413. ) {
  2414. pointerDownState.resize.arrowDirection = getResizeArrowDirection(
  2415. pointerDownState.resize.handleType,
  2416. selectedElements[0],
  2417. );
  2418. }
  2419. } else {
  2420. if (this.state.editingLinearElement) {
  2421. const ret = LinearElementEditor.handlePointerDown(
  2422. event,
  2423. this.state,
  2424. (appState) => this.setState(appState),
  2425. history,
  2426. pointerDownState.origin,
  2427. );
  2428. if (ret.hitElement) {
  2429. pointerDownState.hit.element = ret.hitElement;
  2430. }
  2431. if (ret.didAddPoint) {
  2432. return true;
  2433. }
  2434. }
  2435. // hitElement may already be set above, so check first
  2436. pointerDownState.hit.element =
  2437. pointerDownState.hit.element ??
  2438. this.getElementAtPosition(
  2439. pointerDownState.origin.x,
  2440. pointerDownState.origin.y,
  2441. );
  2442. // For overlapped elements one position may hit
  2443. // multiple elements
  2444. pointerDownState.hit.allHitElements = this.getElementsAtPosition(
  2445. pointerDownState.origin.x,
  2446. pointerDownState.origin.y,
  2447. );
  2448. const hitElement = pointerDownState.hit.element;
  2449. const someHitElementIsSelected = pointerDownState.hit.allHitElements.some(
  2450. (element) => this.isASelectedElement(element),
  2451. );
  2452. if (
  2453. (hitElement === null || !someHitElementIsSelected) &&
  2454. !event.shiftKey &&
  2455. !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
  2456. ) {
  2457. this.clearSelection(hitElement);
  2458. }
  2459. // If we click on something
  2460. if (hitElement != null) {
  2461. // on CMD/CTRL, drill down to hit element regardless of groups etc.
  2462. if (event[KEYS.CTRL_OR_CMD]) {
  2463. this.setState((prevState) => ({
  2464. ...editGroupForSelectedElement(prevState, hitElement),
  2465. previousSelectedElementIds: this.state.selectedElementIds,
  2466. }));
  2467. // mark as not completely handled so as to allow dragging etc.
  2468. return false;
  2469. }
  2470. // deselect if item is selected
  2471. // if shift is not clicked, this will always return true
  2472. // otherwise, it will trigger selection based on current
  2473. // state of the box
  2474. if (!this.state.selectedElementIds[hitElement.id]) {
  2475. // if we are currently editing a group, treat all selections outside of the group
  2476. // as exiting editing mode.
  2477. if (
  2478. this.state.editingGroupId &&
  2479. !isElementInGroup(hitElement, this.state.editingGroupId)
  2480. ) {
  2481. this.setState({
  2482. selectedElementIds: {},
  2483. selectedGroupIds: {},
  2484. editingGroupId: null,
  2485. });
  2486. return true;
  2487. }
  2488. // Add hit element to selection. At this point if we're not holding
  2489. // SHIFT the previously selected element(s) were deselected above
  2490. // (make sure you use setState updater to use latest state)
  2491. if (
  2492. !someHitElementIsSelected &&
  2493. !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
  2494. ) {
  2495. this.setState((prevState) => {
  2496. return selectGroupsForSelectedElements(
  2497. {
  2498. ...prevState,
  2499. selectedElementIds: {
  2500. ...prevState.selectedElementIds,
  2501. [hitElement.id]: true,
  2502. },
  2503. },
  2504. this.scene.getElements(),
  2505. );
  2506. });
  2507. pointerDownState.hit.wasAddedToSelection = true;
  2508. }
  2509. }
  2510. }
  2511. this.setState({
  2512. previousSelectedElementIds: this.state.selectedElementIds,
  2513. });
  2514. }
  2515. }
  2516. return false;
  2517. };
  2518. private isASelectedElement(hitElement: ExcalidrawElement | null): boolean {
  2519. return hitElement != null && this.state.selectedElementIds[hitElement.id];
  2520. }
  2521. private isHittingCommonBoundingBoxOfSelectedElements(
  2522. point: Readonly<{ x: number; y: number }>,
  2523. selectedElements: readonly ExcalidrawElement[],
  2524. ): boolean {
  2525. if (selectedElements.length < 2) {
  2526. return false;
  2527. }
  2528. // How many pixels off the shape boundary we still consider a hit
  2529. const threshold = 10 / this.state.zoom;
  2530. const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
  2531. return (
  2532. point.x > x1 - threshold &&
  2533. point.x < x2 + threshold &&
  2534. point.y > y1 - threshold &&
  2535. point.y < y2 + threshold
  2536. );
  2537. }
  2538. private handleTextOnPointerDown = (
  2539. event: React.PointerEvent<HTMLCanvasElement>,
  2540. pointerDownState: PointerDownState,
  2541. ): void => {
  2542. // if we're currently still editing text, clicking outside
  2543. // should only finalize it, not create another (irrespective
  2544. // of state.elementLocked)
  2545. if (this.state.editingElement?.type === "text") {
  2546. return;
  2547. }
  2548. this.startTextEditing({
  2549. sceneX: pointerDownState.origin.x,
  2550. sceneY: pointerDownState.origin.y,
  2551. insertAtParentCenter: !event.altKey,
  2552. });
  2553. resetCursor();
  2554. if (!this.state.elementLocked) {
  2555. this.setState({
  2556. elementType: "selection",
  2557. });
  2558. }
  2559. };
  2560. private handleLinearElementOnPointerDown = (
  2561. event: React.PointerEvent<HTMLCanvasElement>,
  2562. elementType: ExcalidrawLinearElement["type"],
  2563. pointerDownState: PointerDownState,
  2564. ): void => {
  2565. if (this.state.multiElement) {
  2566. const { multiElement } = this.state;
  2567. // finalize if completing a loop
  2568. if (multiElement.type === "line" && isPathALoop(multiElement.points)) {
  2569. mutateElement(multiElement, {
  2570. lastCommittedPoint:
  2571. multiElement.points[multiElement.points.length - 1],
  2572. });
  2573. this.actionManager.executeAction(actionFinalize);
  2574. return;
  2575. }
  2576. const { x: rx, y: ry, lastCommittedPoint } = multiElement;
  2577. // clicking inside commit zone → finalize arrow
  2578. if (
  2579. multiElement.points.length > 1 &&
  2580. lastCommittedPoint &&
  2581. distance2d(
  2582. pointerDownState.origin.x - rx,
  2583. pointerDownState.origin.y - ry,
  2584. lastCommittedPoint[0],
  2585. lastCommittedPoint[1],
  2586. ) < LINE_CONFIRM_THRESHOLD
  2587. ) {
  2588. this.actionManager.executeAction(actionFinalize);
  2589. return;
  2590. }
  2591. this.setState((prevState) => ({
  2592. selectedElementIds: {
  2593. ...prevState.selectedElementIds,
  2594. [multiElement.id]: true,
  2595. },
  2596. }));
  2597. // clicking outside commit zone → update reference for last committed
  2598. // point
  2599. mutateElement(multiElement, {
  2600. lastCommittedPoint: multiElement.points[multiElement.points.length - 1],
  2601. });
  2602. document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
  2603. } else {
  2604. const [gridX, gridY] = getGridPoint(
  2605. pointerDownState.origin.x,
  2606. pointerDownState.origin.y,
  2607. elementType === "draw" ? null : this.state.gridSize,
  2608. );
  2609. const element = newLinearElement({
  2610. type: elementType,
  2611. x: gridX,
  2612. y: gridY,
  2613. strokeColor: this.state.currentItemStrokeColor,
  2614. backgroundColor: this.state.currentItemBackgroundColor,
  2615. fillStyle: this.state.currentItemFillStyle,
  2616. strokeWidth: this.state.currentItemStrokeWidth,
  2617. strokeStyle: this.state.currentItemStrokeStyle,
  2618. roughness: this.state.currentItemRoughness,
  2619. opacity: this.state.currentItemOpacity,
  2620. strokeSharpness: this.state.currentItemLinearStrokeSharpness,
  2621. });
  2622. this.setState((prevState) => ({
  2623. selectedElementIds: {
  2624. ...prevState.selectedElementIds,
  2625. [element.id]: false,
  2626. },
  2627. }));
  2628. mutateElement(element, {
  2629. points: [...element.points, [0, 0]],
  2630. });
  2631. const boundElement = getHoveredElementForBinding(
  2632. pointerDownState.origin,
  2633. this.scene,
  2634. );
  2635. this.scene.replaceAllElements([
  2636. ...this.scene.getElementsIncludingDeleted(),
  2637. element,
  2638. ]);
  2639. this.setState({
  2640. draggingElement: element,
  2641. editingElement: element,
  2642. startBoundElement: boundElement,
  2643. suggestedBindings: [],
  2644. });
  2645. }
  2646. };
  2647. private createGenericElementOnPointerDown = (
  2648. elementType: ExcalidrawGenericElement["type"],
  2649. pointerDownState: PointerDownState,
  2650. ): void => {
  2651. const [gridX, gridY] = getGridPoint(
  2652. pointerDownState.origin.x,
  2653. pointerDownState.origin.y,
  2654. this.state.gridSize,
  2655. );
  2656. const element = newElement({
  2657. type: elementType,
  2658. x: gridX,
  2659. y: gridY,
  2660. strokeColor: this.state.currentItemStrokeColor,
  2661. backgroundColor: this.state.currentItemBackgroundColor,
  2662. fillStyle: this.state.currentItemFillStyle,
  2663. strokeWidth: this.state.currentItemStrokeWidth,
  2664. strokeStyle: this.state.currentItemStrokeStyle,
  2665. roughness: this.state.currentItemRoughness,
  2666. opacity: this.state.currentItemOpacity,
  2667. strokeSharpness: this.state.currentItemStrokeSharpness,
  2668. });
  2669. if (element.type === "selection") {
  2670. this.setState({
  2671. selectionElement: element,
  2672. draggingElement: element,
  2673. });
  2674. } else {
  2675. this.scene.replaceAllElements([
  2676. ...this.scene.getElementsIncludingDeleted(),
  2677. element,
  2678. ]);
  2679. this.setState({
  2680. multiElement: null,
  2681. draggingElement: element,
  2682. editingElement: element,
  2683. });
  2684. }
  2685. };
  2686. private onPointerMoveFromPointerDownHandler(
  2687. pointerDownState: PointerDownState,
  2688. ): (event: PointerEvent) => void {
  2689. return withBatchedUpdates((event: PointerEvent) => {
  2690. // We need to initialize dragOffsetXY only after we've updated
  2691. // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
  2692. // event handler should hopefully ensure we're already working with
  2693. // the updated state.
  2694. if (pointerDownState.drag.offset === null) {
  2695. pointerDownState.drag.offset = tupleToCoors(
  2696. getDragOffsetXY(
  2697. getSelectedElements(this.scene.getElements(), this.state),
  2698. pointerDownState.origin.x,
  2699. pointerDownState.origin.y,
  2700. ),
  2701. );
  2702. }
  2703. const target = event.target;
  2704. if (!(target instanceof HTMLElement)) {
  2705. return;
  2706. }
  2707. if (this.handlePointerMoveOverScrollbars(event, pointerDownState)) {
  2708. return;
  2709. }
  2710. const pointerCoords = viewportCoordsToSceneCoords(
  2711. event,
  2712. this.state,
  2713. this.canvas,
  2714. window.devicePixelRatio,
  2715. );
  2716. const [gridX, gridY] = getGridPoint(
  2717. pointerCoords.x,
  2718. pointerCoords.y,
  2719. this.state.gridSize,
  2720. );
  2721. // for arrows/lines, don't start dragging until a given threshold
  2722. // to ensure we don't create a 2-point arrow by mistake when
  2723. // user clicks mouse in a way that it moves a tiny bit (thus
  2724. // triggering pointermove)
  2725. if (
  2726. !pointerDownState.drag.hasOccurred &&
  2727. (this.state.elementType === "arrow" ||
  2728. this.state.elementType === "line")
  2729. ) {
  2730. if (
  2731. distance2d(
  2732. pointerCoords.x,
  2733. pointerCoords.y,
  2734. pointerDownState.origin.x,
  2735. pointerDownState.origin.y,
  2736. ) < DRAGGING_THRESHOLD
  2737. ) {
  2738. return;
  2739. }
  2740. }
  2741. if (pointerDownState.resize.isResizing) {
  2742. const selectedElements = getSelectedElements(
  2743. this.scene.getElements(),
  2744. this.state,
  2745. );
  2746. const transformHandleType = pointerDownState.resize.handleType;
  2747. this.setState({
  2748. // TODO: rename this state field to "isScaling" to distinguish
  2749. // it from the generic "isResizing" which includes scaling and
  2750. // rotating
  2751. isResizing: transformHandleType && transformHandleType !== "rotation",
  2752. isRotating: transformHandleType === "rotation",
  2753. });
  2754. const [resizeX, resizeY] = getGridPoint(
  2755. pointerCoords.x - pointerDownState.resize.offset.x,
  2756. pointerCoords.y - pointerDownState.resize.offset.y,
  2757. this.state.gridSize,
  2758. );
  2759. if (
  2760. transformElements(
  2761. pointerDownState,
  2762. transformHandleType,
  2763. (newTransformHandle) => {
  2764. pointerDownState.resize.handleType = newTransformHandle;
  2765. },
  2766. selectedElements,
  2767. pointerDownState.resize.arrowDirection,
  2768. getRotateWithDiscreteAngleKey(event),
  2769. getResizeWithSidesSameLengthKey(event),
  2770. getResizeCenterPointKey(event),
  2771. resizeX,
  2772. resizeY,
  2773. pointerDownState.resize.center.x,
  2774. pointerDownState.resize.center.y,
  2775. )
  2776. ) {
  2777. this.maybeSuggestBindingForAll(selectedElements);
  2778. return;
  2779. }
  2780. }
  2781. if (this.state.editingLinearElement) {
  2782. const didDrag = LinearElementEditor.handlePointDragging(
  2783. this.state,
  2784. (appState) => this.setState(appState),
  2785. pointerCoords.x,
  2786. pointerCoords.y,
  2787. (element, startOrEnd) => {
  2788. this.maybeSuggestBindingForLinearElementAtCursor(
  2789. element,
  2790. startOrEnd,
  2791. pointerCoords,
  2792. );
  2793. },
  2794. );
  2795. if (didDrag) {
  2796. pointerDownState.lastCoords.x = pointerCoords.x;
  2797. pointerDownState.lastCoords.y = pointerCoords.y;
  2798. return;
  2799. }
  2800. }
  2801. const hasHitASelectedElement = pointerDownState.hit.allHitElements.some(
  2802. (element) => this.isASelectedElement(element),
  2803. );
  2804. if (
  2805. hasHitASelectedElement ||
  2806. pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
  2807. ) {
  2808. // Marking that click was used for dragging to check
  2809. // if elements should be deselected on pointerup
  2810. pointerDownState.drag.hasOccurred = true;
  2811. const selectedElements = getSelectedElements(
  2812. this.scene.getElements(),
  2813. this.state,
  2814. );
  2815. if (selectedElements.length > 0) {
  2816. const [dragX, dragY] = getGridPoint(
  2817. pointerCoords.x - pointerDownState.drag.offset.x,
  2818. pointerCoords.y - pointerDownState.drag.offset.y,
  2819. this.state.gridSize,
  2820. );
  2821. const [dragDistanceX, dragDistanceY] = [
  2822. Math.abs(pointerCoords.x - pointerDownState.origin.x),
  2823. Math.abs(pointerCoords.y - pointerDownState.origin.y),
  2824. ];
  2825. // We only drag in one direction if shift is pressed
  2826. const lockDirection = event.shiftKey;
  2827. dragSelectedElements(
  2828. pointerDownState,
  2829. selectedElements,
  2830. dragX,
  2831. dragY,
  2832. this.scene,
  2833. lockDirection,
  2834. dragDistanceX,
  2835. dragDistanceY,
  2836. );
  2837. this.maybeSuggestBindingForAll(selectedElements);
  2838. // We duplicate the selected element if alt is pressed on pointer move
  2839. if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
  2840. // Move the currently selected elements to the top of the z index stack, and
  2841. // put the duplicates where the selected elements used to be.
  2842. // (the origin point where the dragging started)
  2843. pointerDownState.hit.hasBeenDuplicated = true;
  2844. const nextElements = [];
  2845. const elementsToAppend = [];
  2846. const groupIdMap = new Map();
  2847. const oldIdToDuplicatedId = new Map();
  2848. const hitElement = pointerDownState.hit.element;
  2849. for (const element of this.scene.getElementsIncludingDeleted()) {
  2850. if (
  2851. this.state.selectedElementIds[element.id] ||
  2852. // case: the state.selectedElementIds might not have been
  2853. // updated yet by the time this mousemove event is fired
  2854. (element.id === hitElement?.id &&
  2855. pointerDownState.hit.wasAddedToSelection)
  2856. ) {
  2857. const duplicatedElement = duplicateElement(
  2858. this.state.editingGroupId,
  2859. groupIdMap,
  2860. element,
  2861. );
  2862. const [originDragX, originDragY] = getGridPoint(
  2863. pointerDownState.origin.x - pointerDownState.drag.offset.x,
  2864. pointerDownState.origin.y - pointerDownState.drag.offset.y,
  2865. this.state.gridSize,
  2866. );
  2867. mutateElement(duplicatedElement, {
  2868. x: duplicatedElement.x + (originDragX - dragX),
  2869. y: duplicatedElement.y + (originDragY - dragY),
  2870. });
  2871. nextElements.push(duplicatedElement);
  2872. elementsToAppend.push(element);
  2873. oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
  2874. } else {
  2875. nextElements.push(element);
  2876. }
  2877. }
  2878. const nextSceneElements = [...nextElements, ...elementsToAppend];
  2879. fixBindingsAfterDuplication(
  2880. nextSceneElements,
  2881. elementsToAppend,
  2882. oldIdToDuplicatedId,
  2883. "duplicatesServeAsOld",
  2884. );
  2885. this.scene.replaceAllElements(nextSceneElements);
  2886. }
  2887. return;
  2888. }
  2889. }
  2890. // It is very important to read this.state within each move event,
  2891. // otherwise we would read a stale one!
  2892. const draggingElement = this.state.draggingElement;
  2893. if (!draggingElement) {
  2894. return;
  2895. }
  2896. if (isLinearElement(draggingElement)) {
  2897. pointerDownState.drag.hasOccurred = true;
  2898. const points = draggingElement.points;
  2899. let dx: number;
  2900. let dy: number;
  2901. if (draggingElement.type === "draw") {
  2902. dx = pointerCoords.x - draggingElement.x;
  2903. dy = pointerCoords.y - draggingElement.y;
  2904. } else {
  2905. dx = gridX - draggingElement.x;
  2906. dy = gridY - draggingElement.y;
  2907. }
  2908. if (getRotateWithDiscreteAngleKey(event) && points.length === 2) {
  2909. ({ width: dx, height: dy } = getPerfectElementSize(
  2910. this.state.elementType,
  2911. dx,
  2912. dy,
  2913. ));
  2914. }
  2915. if (points.length === 1) {
  2916. mutateElement(draggingElement, { points: [...points, [dx, dy]] });
  2917. } else if (points.length > 1) {
  2918. if (draggingElement.type === "draw") {
  2919. mutateElement(draggingElement, {
  2920. points: simplify(
  2921. [...(points as Point[]), [dx, dy]],
  2922. 0.7 / this.state.zoom,
  2923. ),
  2924. });
  2925. } else {
  2926. mutateElement(draggingElement, {
  2927. points: [...points.slice(0, -1), [dx, dy]],
  2928. });
  2929. }
  2930. }
  2931. if (isBindingElement(draggingElement)) {
  2932. // When creating a linear element by dragging
  2933. this.maybeSuggestBindingForLinearElementAtCursor(
  2934. draggingElement,
  2935. "end",
  2936. pointerCoords,
  2937. this.state.startBoundElement,
  2938. );
  2939. }
  2940. } else if (draggingElement.type === "selection") {
  2941. dragNewElement(
  2942. draggingElement,
  2943. this.state.elementType,
  2944. pointerDownState.origin.x,
  2945. pointerDownState.origin.y,
  2946. pointerCoords.x,
  2947. pointerCoords.y,
  2948. distance(pointerDownState.origin.x, pointerCoords.x),
  2949. distance(pointerDownState.origin.y, pointerCoords.y),
  2950. getResizeWithSidesSameLengthKey(event),
  2951. getResizeCenterPointKey(event),
  2952. );
  2953. } else {
  2954. dragNewElement(
  2955. draggingElement,
  2956. this.state.elementType,
  2957. pointerDownState.originInGrid.x,
  2958. pointerDownState.originInGrid.y,
  2959. gridX,
  2960. gridY,
  2961. distance(pointerDownState.originInGrid.x, gridX),
  2962. distance(pointerDownState.originInGrid.y, gridY),
  2963. getResizeWithSidesSameLengthKey(event),
  2964. getResizeCenterPointKey(event),
  2965. );
  2966. this.maybeSuggestBindingForAll([draggingElement]);
  2967. }
  2968. if (this.state.elementType === "selection") {
  2969. const elements = this.scene.getElements();
  2970. if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
  2971. this.setState({
  2972. selectedElementIds: {},
  2973. selectedGroupIds: {},
  2974. editingGroupId: null,
  2975. });
  2976. }
  2977. const elementsWithinSelection = getElementsWithinSelection(
  2978. elements,
  2979. draggingElement,
  2980. );
  2981. this.setState((prevState) =>
  2982. selectGroupsForSelectedElements(
  2983. {
  2984. ...prevState,
  2985. selectedElementIds: {
  2986. ...prevState.selectedElementIds,
  2987. ...elementsWithinSelection.reduce((map, element) => {
  2988. map[element.id] = true;
  2989. return map;
  2990. }, {} as any),
  2991. },
  2992. },
  2993. this.scene.getElements(),
  2994. ),
  2995. );
  2996. }
  2997. });
  2998. }
  2999. // Returns whether the pointer move happened over either scrollbar
  3000. private handlePointerMoveOverScrollbars(
  3001. event: PointerEvent,
  3002. pointerDownState: PointerDownState,
  3003. ): boolean {
  3004. if (pointerDownState.scrollbars.isOverHorizontal) {
  3005. const x = event.clientX;
  3006. const dx = x - pointerDownState.lastCoords.x;
  3007. this.setState({
  3008. scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
  3009. });
  3010. pointerDownState.lastCoords.x = x;
  3011. return true;
  3012. }
  3013. if (pointerDownState.scrollbars.isOverVertical) {
  3014. const y = event.clientY;
  3015. const dy = y - pointerDownState.lastCoords.y;
  3016. this.setState({
  3017. scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
  3018. });
  3019. pointerDownState.lastCoords.y = y;
  3020. return true;
  3021. }
  3022. return false;
  3023. }
  3024. private onPointerUpFromPointerDownHandler(
  3025. pointerDownState: PointerDownState,
  3026. ): (event: PointerEvent) => void {
  3027. return withBatchedUpdates((childEvent: PointerEvent) => {
  3028. const {
  3029. draggingElement,
  3030. resizingElement,
  3031. multiElement,
  3032. elementType,
  3033. elementLocked,
  3034. isResizing,
  3035. isRotating,
  3036. } = this.state;
  3037. this.setState({
  3038. isResizing: false,
  3039. isRotating: false,
  3040. resizingElement: null,
  3041. selectionElement: null,
  3042. cursorButton: "up",
  3043. // text elements are reset on finalize, and resetting on pointerup
  3044. // may cause issues with double taps
  3045. editingElement:
  3046. multiElement || isTextElement(this.state.editingElement)
  3047. ? this.state.editingElement
  3048. : null,
  3049. });
  3050. this.savePointer(childEvent.clientX, childEvent.clientY, "up");
  3051. // Handle end of dragging a point of a linear element, might close a loop
  3052. // and sets binding element
  3053. if (this.state.editingLinearElement) {
  3054. const editingLinearElement = LinearElementEditor.handlePointerUp(
  3055. childEvent,
  3056. this.state.editingLinearElement,
  3057. this.state,
  3058. );
  3059. if (editingLinearElement !== this.state.editingLinearElement) {
  3060. this.setState({
  3061. editingLinearElement,
  3062. suggestedBindings: [],
  3063. });
  3064. }
  3065. }
  3066. lastPointerUp = null;
  3067. window.removeEventListener(
  3068. EVENT.POINTER_MOVE,
  3069. pointerDownState.eventListeners.onMove!,
  3070. );
  3071. window.removeEventListener(
  3072. EVENT.POINTER_UP,
  3073. pointerDownState.eventListeners.onUp!,
  3074. );
  3075. if (draggingElement?.type === "draw") {
  3076. this.actionManager.executeAction(actionFinalize);
  3077. return;
  3078. }
  3079. if (isLinearElement(draggingElement)) {
  3080. if (draggingElement!.points.length > 1) {
  3081. history.resumeRecording();
  3082. }
  3083. const pointerCoords = viewportCoordsToSceneCoords(
  3084. childEvent,
  3085. this.state,
  3086. this.canvas,
  3087. window.devicePixelRatio,
  3088. );
  3089. if (
  3090. !pointerDownState.drag.hasOccurred &&
  3091. draggingElement &&
  3092. !multiElement
  3093. ) {
  3094. mutateElement(draggingElement, {
  3095. points: [
  3096. ...draggingElement.points,
  3097. [
  3098. pointerCoords.x - draggingElement.x,
  3099. pointerCoords.y - draggingElement.y,
  3100. ],
  3101. ],
  3102. });
  3103. this.setState({
  3104. multiElement: draggingElement,
  3105. editingElement: this.state.draggingElement,
  3106. });
  3107. } else if (pointerDownState.drag.hasOccurred && !multiElement) {
  3108. if (
  3109. isBindingEnabled(this.state) &&
  3110. isBindingElement(draggingElement)
  3111. ) {
  3112. maybeBindLinearElement(
  3113. draggingElement,
  3114. this.state,
  3115. this.scene,
  3116. pointerCoords,
  3117. );
  3118. }
  3119. this.setState({ suggestedBindings: [], startBoundElement: null });
  3120. if (!elementLocked) {
  3121. resetCursor();
  3122. this.setState((prevState) => ({
  3123. draggingElement: null,
  3124. elementType: "selection",
  3125. selectedElementIds: {
  3126. ...prevState.selectedElementIds,
  3127. [this.state.draggingElement!.id]: true,
  3128. },
  3129. }));
  3130. } else {
  3131. this.setState((prevState) => ({
  3132. draggingElement: null,
  3133. selectedElementIds: {
  3134. ...prevState.selectedElementIds,
  3135. [this.state.draggingElement!.id]: true,
  3136. },
  3137. }));
  3138. }
  3139. }
  3140. return;
  3141. }
  3142. if (
  3143. elementType !== "selection" &&
  3144. draggingElement &&
  3145. isInvisiblySmallElement(draggingElement)
  3146. ) {
  3147. // remove invisible element which was added in onPointerDown
  3148. this.scene.replaceAllElements(
  3149. this.scene.getElementsIncludingDeleted().slice(0, -1),
  3150. );
  3151. this.setState({
  3152. draggingElement: null,
  3153. });
  3154. return;
  3155. }
  3156. if (draggingElement) {
  3157. mutateElement(
  3158. draggingElement,
  3159. getNormalizedDimensions(draggingElement),
  3160. );
  3161. }
  3162. if (resizingElement) {
  3163. history.resumeRecording();
  3164. }
  3165. if (resizingElement && isInvisiblySmallElement(resizingElement)) {
  3166. this.scene.replaceAllElements(
  3167. this.scene
  3168. .getElementsIncludingDeleted()
  3169. .filter((el) => el.id !== resizingElement.id),
  3170. );
  3171. }
  3172. // Code below handles selection when element(s) weren't
  3173. // drag or added to selection on pointer down phase.
  3174. const hitElement = pointerDownState.hit.element;
  3175. if (
  3176. hitElement &&
  3177. !pointerDownState.drag.hasOccurred &&
  3178. !pointerDownState.hit.wasAddedToSelection
  3179. ) {
  3180. if (childEvent.shiftKey) {
  3181. if (this.state.selectedElementIds[hitElement.id]) {
  3182. if (isSelectedViaGroup(this.state, hitElement)) {
  3183. // We want to unselect all groups hitElement is part of
  3184. // as well as all elements that are part of the groups
  3185. // hitElement is part of
  3186. const idsOfSelectedElementsThatAreInGroups = hitElement.groupIds
  3187. .flatMap((groupId) =>
  3188. getElementsInGroup(this.scene.getElements(), groupId),
  3189. )
  3190. .map((element) => ({ [element.id]: false }))
  3191. .reduce((prevId, acc) => ({ ...prevId, ...acc }), {});
  3192. this.setState((_prevState) => ({
  3193. selectedGroupIds: {
  3194. ..._prevState.selectedElementIds,
  3195. ...hitElement.groupIds
  3196. .map((gId) => ({ [gId]: false }))
  3197. .reduce((prev, acc) => ({ ...prev, ...acc }), {}),
  3198. },
  3199. selectedElementIds: {
  3200. ..._prevState.selectedElementIds,
  3201. ...idsOfSelectedElementsThatAreInGroups,
  3202. },
  3203. }));
  3204. } else {
  3205. // remove element from selection while
  3206. // keeping prev elements selected
  3207. this.setState((prevState) => ({
  3208. selectedElementIds: {
  3209. ...prevState.selectedElementIds,
  3210. [hitElement!.id]: false,
  3211. },
  3212. }));
  3213. }
  3214. } else {
  3215. // add element to selection while
  3216. // keeping prev elements selected
  3217. this.setState((_prevState) => ({
  3218. selectedElementIds: {
  3219. ..._prevState.selectedElementIds,
  3220. [hitElement!.id]: true,
  3221. },
  3222. }));
  3223. }
  3224. } else {
  3225. this.setState((prevState) => ({
  3226. ...selectGroupsForSelectedElements(
  3227. {
  3228. ...prevState,
  3229. selectedElementIds: { [hitElement.id]: true },
  3230. },
  3231. this.scene.getElements(),
  3232. ),
  3233. }));
  3234. }
  3235. }
  3236. if (
  3237. !this.state.editingLinearElement &&
  3238. !pointerDownState.drag.hasOccurred &&
  3239. !this.state.isResizing &&
  3240. ((hitElement &&
  3241. isHittingElementBoundingBoxWithoutHittingElement(
  3242. hitElement,
  3243. this.state,
  3244. pointerDownState.origin.x,
  3245. pointerDownState.origin.y,
  3246. )) ||
  3247. (!hitElement &&
  3248. pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
  3249. ) {
  3250. // Deselect selected elements
  3251. this.setState({
  3252. selectedElementIds: {},
  3253. selectedGroupIds: {},
  3254. editingGroupId: null,
  3255. });
  3256. return;
  3257. }
  3258. if (!elementLocked && draggingElement) {
  3259. this.setState((prevState) => ({
  3260. selectedElementIds: {
  3261. ...prevState.selectedElementIds,
  3262. [draggingElement.id]: true,
  3263. },
  3264. }));
  3265. }
  3266. if (
  3267. elementType !== "selection" ||
  3268. isSomeElementSelected(this.scene.getElements(), this.state)
  3269. ) {
  3270. history.resumeRecording();
  3271. }
  3272. if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
  3273. (isBindingEnabled(this.state)
  3274. ? bindOrUnbindSelectedElements
  3275. : unbindLinearElements)(
  3276. getSelectedElements(this.scene.getElements(), this.state),
  3277. );
  3278. }
  3279. if (!elementLocked) {
  3280. resetCursor();
  3281. this.setState({
  3282. draggingElement: null,
  3283. suggestedBindings: [],
  3284. elementType: "selection",
  3285. });
  3286. } else {
  3287. this.setState({
  3288. draggingElement: null,
  3289. suggestedBindings: [],
  3290. });
  3291. }
  3292. });
  3293. }
  3294. private updateBindingEnabledOnPointerMove = (
  3295. event: React.PointerEvent<HTMLCanvasElement>,
  3296. ) => {
  3297. const shouldEnableBinding = shouldEnableBindingForPointerEvent(event);
  3298. if (this.state.isBindingEnabled !== shouldEnableBinding) {
  3299. this.setState({ isBindingEnabled: shouldEnableBinding });
  3300. }
  3301. };
  3302. private maybeSuggestBindingAtCursor = (pointerCoords: {
  3303. x: number;
  3304. y: number;
  3305. }): void => {
  3306. const hoveredBindableElement = getHoveredElementForBinding(
  3307. pointerCoords,
  3308. this.scene,
  3309. );
  3310. this.setState({
  3311. suggestedBindings:
  3312. hoveredBindableElement != null ? [hoveredBindableElement] : [],
  3313. });
  3314. };
  3315. private maybeSuggestBindingForLinearElementAtCursor = (
  3316. linearElement: NonDeleted<ExcalidrawLinearElement>,
  3317. startOrEnd: "start" | "end",
  3318. pointerCoords: {
  3319. x: number;
  3320. y: number;
  3321. },
  3322. // During line creation the start binding hasn't been written yet
  3323. // into `linearElement`
  3324. oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
  3325. ): void => {
  3326. const hoveredBindableElement = getHoveredElementForBinding(
  3327. pointerCoords,
  3328. this.scene,
  3329. );
  3330. this.setState({
  3331. suggestedBindings:
  3332. hoveredBindableElement != null &&
  3333. !isLinearElementSimpleAndAlreadyBound(
  3334. linearElement,
  3335. oppositeBindingBoundElement?.id,
  3336. hoveredBindableElement,
  3337. )
  3338. ? [hoveredBindableElement]
  3339. : [],
  3340. });
  3341. };
  3342. private maybeSuggestBindingForAll(
  3343. selectedElements: NonDeleted<ExcalidrawElement>[],
  3344. ): void {
  3345. const suggestedBindings = getEligibleElementsForBinding(selectedElements);
  3346. this.setState({ suggestedBindings });
  3347. }
  3348. private clearSelection(hitElement: ExcalidrawElement | null): void {
  3349. this.setState((prevState) => ({
  3350. selectedElementIds: {},
  3351. selectedGroupIds: {},
  3352. // Continue editing the same group if the user selected a different
  3353. // element from it
  3354. editingGroupId:
  3355. prevState.editingGroupId &&
  3356. hitElement != null &&
  3357. isElementInGroup(hitElement, prevState.editingGroupId)
  3358. ? prevState.editingGroupId
  3359. : null,
  3360. }));
  3361. this.setState({
  3362. selectedElementIds: {},
  3363. previousSelectedElementIds: this.state.selectedElementIds,
  3364. });
  3365. }
  3366. private handleCanvasRef = (canvas: HTMLCanvasElement) => {
  3367. // canvas is null when unmounting
  3368. if (canvas !== null) {
  3369. this.canvas = canvas;
  3370. this.rc = rough.canvas(this.canvas);
  3371. this.canvas.addEventListener(EVENT.WHEEL, this.handleWheel, {
  3372. passive: false,
  3373. });
  3374. this.canvas.addEventListener(EVENT.TOUCH_START, this.onTapStart);
  3375. this.canvas.addEventListener(EVENT.TOUCH_END, this.onTapEnd);
  3376. } else {
  3377. this.canvas?.removeEventListener(EVENT.WHEEL, this.handleWheel);
  3378. this.canvas?.removeEventListener(EVENT.TOUCH_START, this.onTapStart);
  3379. this.canvas?.removeEventListener(EVENT.TOUCH_END, this.onTapEnd);
  3380. }
  3381. };
  3382. private handleCanvasOnDrop = async (
  3383. event: React.DragEvent<HTMLCanvasElement>,
  3384. ) => {
  3385. try {
  3386. const file = event.dataTransfer.files[0];
  3387. if (file?.type === "image/png" || file?.type === "image/svg+xml") {
  3388. const { elements, appState } = await loadFromBlob(file, this.state);
  3389. this.syncActionResult({
  3390. elements,
  3391. appState: {
  3392. ...(appState || this.state),
  3393. isLoading: false,
  3394. },
  3395. commitToHistory: true,
  3396. });
  3397. return;
  3398. }
  3399. } catch (error) {
  3400. return this.setState({
  3401. isLoading: false,
  3402. errorMessage: error.message,
  3403. });
  3404. }
  3405. const libraryShapes = event.dataTransfer.getData(MIME_TYPES.excalidraw);
  3406. if (libraryShapes !== "") {
  3407. this.addElementsFromPasteOrLibrary(
  3408. JSON.parse(libraryShapes),
  3409. event.clientX,
  3410. event.clientY,
  3411. );
  3412. return;
  3413. }
  3414. const file = event.dataTransfer?.files[0];
  3415. if (
  3416. file?.type === "application/json" ||
  3417. file?.name.endsWith(".excalidraw")
  3418. ) {
  3419. this.setState({ isLoading: true });
  3420. if (
  3421. "chooseFileSystemEntries" in window ||
  3422. "showOpenFilePicker" in window
  3423. ) {
  3424. try {
  3425. // This will only work as of Chrome 86,
  3426. // but can be safely ignored on older releases.
  3427. const item = event.dataTransfer.items[0];
  3428. // TODO: Make this part of `AppState`.
  3429. (file as any).handle = await (item as any).getAsFileSystemHandle();
  3430. } catch (error) {
  3431. console.warn(error.name, error.message);
  3432. }
  3433. }
  3434. loadFromBlob(file, this.state)
  3435. .then(({ elements, appState }) =>
  3436. this.syncActionResult({
  3437. elements,
  3438. appState: {
  3439. ...(appState || this.state),
  3440. isLoading: false,
  3441. },
  3442. commitToHistory: true,
  3443. }),
  3444. )
  3445. .catch((error) => {
  3446. this.setState({ isLoading: false, errorMessage: error.message });
  3447. });
  3448. } else if (
  3449. file?.type === MIME_TYPES.excalidrawlib ||
  3450. file?.name.endsWith(".excalidrawlib")
  3451. ) {
  3452. Library.importLibrary(file)
  3453. .then(() => {
  3454. this.setState({ isLibraryOpen: false });
  3455. })
  3456. .catch((error) =>
  3457. this.setState({ isLoading: false, errorMessage: error.message }),
  3458. );
  3459. } else {
  3460. this.setState({
  3461. isLoading: false,
  3462. errorMessage: t("alerts.couldNotLoadInvalidFile"),
  3463. });
  3464. }
  3465. };
  3466. private handleCanvasContextMenu = (
  3467. event: React.PointerEvent<HTMLCanvasElement>,
  3468. ) => {
  3469. event.preventDefault();
  3470. this.openContextMenu(event);
  3471. };
  3472. private openContextMenu = ({
  3473. clientX,
  3474. clientY,
  3475. }: {
  3476. clientX: number;
  3477. clientY: number;
  3478. }) => {
  3479. const { x, y } = viewportCoordsToSceneCoords(
  3480. { clientX, clientY },
  3481. this.state,
  3482. this.canvas,
  3483. window.devicePixelRatio,
  3484. );
  3485. const elements = this.scene.getElements();
  3486. const element = this.getElementAtPosition(x, y);
  3487. if (!element) {
  3488. ContextMenu.push({
  3489. options: [
  3490. navigator.clipboard && {
  3491. label: t("labels.paste"),
  3492. action: () => this.pasteFromClipboard(null),
  3493. },
  3494. probablySupportsClipboardBlob &&
  3495. elements.length > 0 && {
  3496. label: t("labels.copyAsPng"),
  3497. action: this.copyToClipboardAsPng,
  3498. },
  3499. probablySupportsClipboardWriteText &&
  3500. elements.length > 0 && {
  3501. label: t("labels.copyAsSvg"),
  3502. action: this.copyToClipboardAsSvg,
  3503. },
  3504. ...this.actionManager.getContextMenuItems((action) =>
  3505. CANVAS_ONLY_ACTIONS.includes(action.name),
  3506. ),
  3507. {
  3508. label: t("labels.toggleGridMode"),
  3509. action: this.toggleGridMode,
  3510. },
  3511. ],
  3512. top: clientY,
  3513. left: clientX,
  3514. });
  3515. return;
  3516. }
  3517. if (!this.state.selectedElementIds[element.id]) {
  3518. this.setState({ selectedElementIds: { [element.id]: true } });
  3519. }
  3520. ContextMenu.push({
  3521. options: [
  3522. navigator.clipboard && {
  3523. label: t("labels.copy"),
  3524. action: this.copyAll,
  3525. },
  3526. navigator.clipboard && {
  3527. label: t("labels.paste"),
  3528. action: () => this.pasteFromClipboard(null),
  3529. },
  3530. probablySupportsClipboardBlob && {
  3531. label: t("labels.copyAsPng"),
  3532. action: this.copyToClipboardAsPng,
  3533. },
  3534. probablySupportsClipboardWriteText && {
  3535. label: t("labels.copyAsSvg"),
  3536. action: this.copyToClipboardAsSvg,
  3537. },
  3538. ...this.actionManager.getContextMenuItems(
  3539. (action) => !CANVAS_ONLY_ACTIONS.includes(action.name),
  3540. ),
  3541. ],
  3542. top: clientY,
  3543. left: clientX,
  3544. });
  3545. };
  3546. private handleWheel = withBatchedUpdates((event: WheelEvent) => {
  3547. event.preventDefault();
  3548. if (isPanning) {
  3549. return;
  3550. }
  3551. const { deltaX, deltaY } = event;
  3552. const { selectedElementIds, previousSelectedElementIds } = this.state;
  3553. // note that event.ctrlKey is necessary to handle pinch zooming
  3554. if (event.metaKey || event.ctrlKey) {
  3555. const sign = Math.sign(deltaY);
  3556. const MAX_STEP = 10;
  3557. let delta = Math.abs(deltaY);
  3558. if (delta > MAX_STEP) {
  3559. delta = MAX_STEP;
  3560. }
  3561. delta *= sign;
  3562. if (Object.keys(previousSelectedElementIds).length !== 0) {
  3563. setTimeout(() => {
  3564. this.setState({
  3565. selectedElementIds: previousSelectedElementIds,
  3566. previousSelectedElementIds: {},
  3567. });
  3568. }, 1000);
  3569. }
  3570. this.setState(({ zoom }) => ({
  3571. zoom: getNormalizedZoom(zoom - delta / 100),
  3572. selectedElementIds: {},
  3573. previousSelectedElementIds:
  3574. Object.keys(selectedElementIds).length !== 0
  3575. ? selectedElementIds
  3576. : previousSelectedElementIds,
  3577. shouldCacheIgnoreZoom: true,
  3578. }));
  3579. this.resetShouldCacheIgnoreZoomDebounced();
  3580. return;
  3581. }
  3582. // scroll horizontally when shift pressed
  3583. if (event.shiftKey) {
  3584. this.setState(({ zoom, scrollX }) => ({
  3585. // on Mac, shift+wheel tends to result in deltaX
  3586. scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom),
  3587. }));
  3588. return;
  3589. }
  3590. this.setState(({ zoom, scrollX, scrollY }) => ({
  3591. scrollX: normalizeScroll(scrollX - deltaX / zoom),
  3592. scrollY: normalizeScroll(scrollY - deltaY / zoom),
  3593. }));
  3594. });
  3595. private getTextWysiwygSnappedToCenterPosition(
  3596. x: number,
  3597. y: number,
  3598. appState: AppState,
  3599. canvas: HTMLCanvasElement | null,
  3600. scale: number,
  3601. ) {
  3602. const elementClickedInside = getElementContainingPosition(
  3603. this.scene
  3604. .getElementsIncludingDeleted()
  3605. .filter((element) => !isTextElement(element)),
  3606. x,
  3607. y,
  3608. );
  3609. if (elementClickedInside) {
  3610. const elementCenterX =
  3611. elementClickedInside.x + elementClickedInside.width / 2;
  3612. const elementCenterY =
  3613. elementClickedInside.y + elementClickedInside.height / 2;
  3614. const distanceToCenter = Math.hypot(
  3615. x - elementCenterX,
  3616. y - elementCenterY,
  3617. );
  3618. const isSnappedToCenter =
  3619. distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
  3620. if (isSnappedToCenter) {
  3621. const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
  3622. { sceneX: elementCenterX, sceneY: elementCenterY },
  3623. appState,
  3624. canvas,
  3625. scale,
  3626. );
  3627. return { viewportX, viewportY, elementCenterX, elementCenterY };
  3628. }
  3629. }
  3630. }
  3631. private savePointer = (x: number, y: number, button: "up" | "down") => {
  3632. if (!x || !y) {
  3633. return;
  3634. }
  3635. const pointer = viewportCoordsToSceneCoords(
  3636. { clientX: x, clientY: y },
  3637. this.state,
  3638. this.canvas,
  3639. window.devicePixelRatio,
  3640. );
  3641. if (isNaN(pointer.x) || isNaN(pointer.y)) {
  3642. // sometimes the pointer goes off screen
  3643. return;
  3644. }
  3645. this.portal.socket &&
  3646. // do not broadcast when more than 1 pointer since that shows flickering on the other side
  3647. gesture.pointers.size < 2 &&
  3648. this.portal.broadcastMouseLocation({
  3649. pointer,
  3650. button,
  3651. });
  3652. };
  3653. private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
  3654. this.setState({ shouldCacheIgnoreZoom: false });
  3655. }, 300);
  3656. private getCanvasOffsets(offsets?: {
  3657. offsetLeft?: number;
  3658. offsetTop?: number;
  3659. }): Pick<AppState, "offsetTop" | "offsetLeft"> {
  3660. if (
  3661. typeof offsets?.offsetLeft === "number" &&
  3662. typeof offsets?.offsetTop === "number"
  3663. ) {
  3664. return {
  3665. offsetLeft: offsets.offsetLeft,
  3666. offsetTop: offsets.offsetTop,
  3667. };
  3668. }
  3669. if (this.excalidrawRef?.current) {
  3670. const parentElement = this.excalidrawRef.current.parentElement;
  3671. const { left, top } = parentElement.getBoundingClientRect();
  3672. return {
  3673. offsetLeft:
  3674. typeof offsets?.offsetLeft === "number" ? offsets.offsetLeft : left,
  3675. offsetTop:
  3676. typeof offsets?.offsetTop === "number" ? offsets.offsetTop : top,
  3677. };
  3678. }
  3679. return {
  3680. offsetLeft:
  3681. typeof offsets?.offsetLeft === "number" ? offsets.offsetLeft : 0,
  3682. offsetTop: typeof offsets?.offsetTop === "number" ? offsets.offsetTop : 0,
  3683. };
  3684. }
  3685. }
  3686. // -----------------------------------------------------------------------------
  3687. // TEST HOOKS
  3688. // -----------------------------------------------------------------------------
  3689. declare global {
  3690. interface Window {
  3691. h: {
  3692. elements: readonly ExcalidrawElement[];
  3693. state: AppState;
  3694. setState: React.Component<any, AppState>["setState"];
  3695. history: SceneHistory;
  3696. app: InstanceType<typeof App>;
  3697. library: ReturnType<typeof loadLibrary>;
  3698. };
  3699. }
  3700. }
  3701. if (
  3702. process.env.NODE_ENV === ENV.TEST ||
  3703. process.env.NODE_ENV === ENV.DEVELOPMENT
  3704. ) {
  3705. window.h = {} as Window["h"];
  3706. Object.defineProperties(window.h, {
  3707. elements: {
  3708. get() {
  3709. return this.app.scene.getElementsIncludingDeleted();
  3710. },
  3711. set(elements: ExcalidrawElement[]) {
  3712. return this.app.scene.replaceAllElements(elements);
  3713. },
  3714. },
  3715. history: {
  3716. get: () => history,
  3717. },
  3718. library: {
  3719. get: () => loadLibrary(),
  3720. },
  3721. });
  3722. }
  3723. export default App;