App.tsx 65 KB

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