App.tsx 116 KB

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