App.tsx 74 KB

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