App.tsx 104 KB

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