App.tsx 89 KB

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