App.tsx 110 KB

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