index.tsx 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573
  1. import React from "react";
  2. import ReactDOM from "react-dom";
  3. import rough from "roughjs/bin/rough";
  4. import { RoughCanvas } from "roughjs/bin/canvas";
  5. import {
  6. newElement,
  7. newTextElement,
  8. duplicateElement,
  9. resizeTest,
  10. normalizeResizeHandle,
  11. isInvisiblySmallElement,
  12. isTextElement,
  13. textWysiwyg,
  14. getElementAbsoluteCoords,
  15. getCursorForResizingElement,
  16. getPerfectElementSize,
  17. resizePerfectLineForNWHandler,
  18. normalizeDimensions,
  19. } from "./element";
  20. import {
  21. clearSelection,
  22. deleteSelectedElements,
  23. getElementsWithinSelection,
  24. isOverScrollBars,
  25. restoreFromLocalStorage,
  26. saveToLocalStorage,
  27. getElementAtPosition,
  28. createScene,
  29. getElementContainingPosition,
  30. hasBackground,
  31. hasStroke,
  32. hasText,
  33. exportCanvas,
  34. importFromBackend,
  35. } from "./scene";
  36. import { renderScene } from "./renderer";
  37. import { AppState } from "./types";
  38. import { ExcalidrawElement } from "./element/types";
  39. import { isInputLike, debounce, capitalizeString, distance } from "./utils";
  40. import { KEYS, isArrowKey } from "./keys";
  41. import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes";
  42. import { createHistory } from "./history";
  43. import ContextMenu from "./components/ContextMenu";
  44. import "./styles.scss";
  45. import { getElementWithResizeHandler } from "./element/resizeTest";
  46. import {
  47. ActionManager,
  48. actionDeleteSelected,
  49. actionSendBackward,
  50. actionBringForward,
  51. actionSendToBack,
  52. actionBringToFront,
  53. actionSelectAll,
  54. actionChangeStrokeColor,
  55. actionChangeBackgroundColor,
  56. actionChangeOpacity,
  57. actionChangeStrokeWidth,
  58. actionChangeFillStyle,
  59. actionChangeSloppiness,
  60. actionChangeFontSize,
  61. actionChangeFontFamily,
  62. actionChangeViewBackgroundColor,
  63. actionClearCanvas,
  64. actionChangeProjectName,
  65. actionChangeExportBackground,
  66. actionLoadScene,
  67. actionSaveScene,
  68. actionCopyStyles,
  69. actionPasteStyles,
  70. } from "./actions";
  71. import { Action, ActionResult } from "./actions/types";
  72. import { getDefaultAppState } from "./appState";
  73. import { Island } from "./components/Island";
  74. import Stack from "./components/Stack";
  75. import { FixedSideContainer } from "./components/FixedSideContainer";
  76. import { ToolButton } from "./components/ToolButton";
  77. import { LockIcon } from "./components/LockIcon";
  78. import { ExportDialog } from "./components/ExportDialog";
  79. import { withTranslation } from "react-i18next";
  80. import { LanguageList } from "./components/LanguageList";
  81. import i18n, { languages, parseDetectedLang } from "./i18n";
  82. let { elements } = createScene();
  83. const { history } = createHistory();
  84. const CANVAS_WINDOW_OFFSET_LEFT = 0;
  85. const CANVAS_WINDOW_OFFSET_TOP = 0;
  86. function resetCursor() {
  87. document.documentElement.style.cursor = "";
  88. }
  89. const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
  90. const ELEMENT_TRANSLATE_AMOUNT = 1;
  91. const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
  92. const CURSOR_TYPE = {
  93. TEXT: "text",
  94. CROSSHAIR: "crosshair",
  95. };
  96. let lastCanvasWidth = -1;
  97. let lastCanvasHeight = -1;
  98. let lastMouseUp: ((e: any) => void) | null = null;
  99. export function viewportCoordsToSceneCoords(
  100. { clientX, clientY }: { clientX: number; clientY: number },
  101. { scrollX, scrollY }: { scrollX: number; scrollY: number },
  102. ) {
  103. const x = clientX - CANVAS_WINDOW_OFFSET_LEFT - scrollX;
  104. const y = clientY - CANVAS_WINDOW_OFFSET_TOP - scrollY;
  105. return { x, y };
  106. }
  107. function pickAppStatePropertiesForHistory(
  108. appState: AppState,
  109. ): Partial<AppState> {
  110. return {
  111. exportBackground: appState.exportBackground,
  112. currentItemStrokeColor: appState.currentItemStrokeColor,
  113. currentItemBackgroundColor: appState.currentItemBackgroundColor,
  114. currentItemFillStyle: appState.currentItemFillStyle,
  115. currentItemStrokeWidth: appState.currentItemStrokeWidth,
  116. currentItemRoughness: appState.currentItemRoughness,
  117. currentItemOpacity: appState.currentItemOpacity,
  118. currentItemFont: appState.currentItemFont,
  119. viewBackgroundColor: appState.viewBackgroundColor,
  120. name: appState.name,
  121. };
  122. }
  123. export class App extends React.Component<any, AppState> {
  124. canvas: HTMLCanvasElement | null = null;
  125. rc: RoughCanvas | null = null;
  126. actionManager: ActionManager = new ActionManager();
  127. canvasOnlyActions: Array<Action>;
  128. constructor(props: any) {
  129. super(props);
  130. this.actionManager.registerAction(actionDeleteSelected);
  131. this.actionManager.registerAction(actionSendToBack);
  132. this.actionManager.registerAction(actionBringToFront);
  133. this.actionManager.registerAction(actionSendBackward);
  134. this.actionManager.registerAction(actionBringForward);
  135. this.actionManager.registerAction(actionSelectAll);
  136. this.actionManager.registerAction(actionChangeStrokeColor);
  137. this.actionManager.registerAction(actionChangeBackgroundColor);
  138. this.actionManager.registerAction(actionChangeFillStyle);
  139. this.actionManager.registerAction(actionChangeStrokeWidth);
  140. this.actionManager.registerAction(actionChangeOpacity);
  141. this.actionManager.registerAction(actionChangeSloppiness);
  142. this.actionManager.registerAction(actionChangeFontSize);
  143. this.actionManager.registerAction(actionChangeFontFamily);
  144. this.actionManager.registerAction(actionChangeViewBackgroundColor);
  145. this.actionManager.registerAction(actionClearCanvas);
  146. this.actionManager.registerAction(actionChangeProjectName);
  147. this.actionManager.registerAction(actionChangeExportBackground);
  148. this.actionManager.registerAction(actionSaveScene);
  149. this.actionManager.registerAction(actionLoadScene);
  150. this.actionManager.registerAction(actionCopyStyles);
  151. this.actionManager.registerAction(actionPasteStyles);
  152. this.canvasOnlyActions = [actionSelectAll];
  153. }
  154. private syncActionResult = (res: ActionResult) => {
  155. if (res.elements !== undefined) {
  156. elements = res.elements;
  157. this.forceUpdate();
  158. }
  159. if (res.appState !== undefined) {
  160. this.setState({ ...res.appState });
  161. }
  162. };
  163. private onCut = (e: ClipboardEvent) => {
  164. if (isInputLike(e.target)) return;
  165. e.clipboardData?.setData(
  166. "text/plain",
  167. JSON.stringify(
  168. elements
  169. .filter(element => element.isSelected)
  170. .map(({ shape, ...el }) => el),
  171. ),
  172. );
  173. elements = deleteSelectedElements(elements);
  174. this.forceUpdate();
  175. e.preventDefault();
  176. };
  177. private onCopy = (e: ClipboardEvent) => {
  178. if (isInputLike(e.target)) return;
  179. e.clipboardData?.setData(
  180. "text/plain",
  181. JSON.stringify(
  182. elements
  183. .filter(element => element.isSelected)
  184. .map(({ shape, ...el }) => el),
  185. ),
  186. );
  187. e.preventDefault();
  188. };
  189. private onPaste = (e: ClipboardEvent) => {
  190. if (isInputLike(e.target)) return;
  191. const paste = e.clipboardData?.getData("text") || "";
  192. this.addElementsFromPaste(paste);
  193. e.preventDefault();
  194. };
  195. private onUnload = () => {
  196. this.saveDebounced();
  197. this.saveDebounced.flush();
  198. };
  199. public async componentDidMount() {
  200. document.addEventListener("copy", this.onCopy);
  201. document.addEventListener("paste", this.onPaste);
  202. document.addEventListener("cut", this.onCut);
  203. document.addEventListener("keydown", this.onKeyDown, false);
  204. document.addEventListener("mousemove", this.getCurrentCursorPosition);
  205. window.addEventListener("resize", this.onResize, false);
  206. window.addEventListener("unload", this.onUnload, false);
  207. let data;
  208. const searchParams = new URLSearchParams(window.location.search);
  209. if (searchParams.get("id") != null) {
  210. data = await importFromBackend(searchParams.get("id"));
  211. window.history.replaceState({}, "Excalidraw", window.location.origin);
  212. } else {
  213. data = restoreFromLocalStorage();
  214. }
  215. if (data.elements) {
  216. elements = data.elements;
  217. }
  218. if (data.appState) {
  219. this.setState(data.appState);
  220. } else {
  221. this.forceUpdate();
  222. }
  223. }
  224. public componentWillUnmount() {
  225. document.removeEventListener("copy", this.onCopy);
  226. document.removeEventListener("paste", this.onPaste);
  227. document.removeEventListener("cut", this.onCut);
  228. document.removeEventListener("keydown", this.onKeyDown, false);
  229. document.removeEventListener(
  230. "mousemove",
  231. this.getCurrentCursorPosition,
  232. false,
  233. );
  234. window.removeEventListener("resize", this.onResize, false);
  235. window.removeEventListener("unload", this.onUnload, false);
  236. }
  237. public state: AppState = getDefaultAppState();
  238. private onResize = () => {
  239. this.forceUpdate();
  240. };
  241. private getCurrentCursorPosition = (e: MouseEvent) => {
  242. this.setState({ cursorX: e.x, cursorY: e.y });
  243. };
  244. private onKeyDown = (event: KeyboardEvent) => {
  245. if (event.key === KEYS.ESCAPE) {
  246. elements = clearSelection(elements);
  247. this.forceUpdate();
  248. this.setState({ elementType: "selection" });
  249. if (window.document.activeElement instanceof HTMLElement) {
  250. window.document.activeElement.blur();
  251. }
  252. event.preventDefault();
  253. return;
  254. }
  255. if (isInputLike(event.target)) return;
  256. const data = this.actionManager.handleKeyDown(event, elements, this.state);
  257. this.syncActionResult(data);
  258. if (data.elements !== undefined || data.appState !== undefined) {
  259. return;
  260. }
  261. const shape = findShapeByKey(event.key);
  262. if (isArrowKey(event.key)) {
  263. const step = event.shiftKey
  264. ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
  265. : ELEMENT_TRANSLATE_AMOUNT;
  266. elements = elements.map(el => {
  267. if (el.isSelected) {
  268. const element = { ...el };
  269. if (event.key === KEYS.ARROW_LEFT) element.x -= step;
  270. else if (event.key === KEYS.ARROW_RIGHT) element.x += step;
  271. else if (event.key === KEYS.ARROW_UP) element.y -= step;
  272. else if (event.key === KEYS.ARROW_DOWN) element.y += step;
  273. return element;
  274. }
  275. return el;
  276. });
  277. this.forceUpdate();
  278. event.preventDefault();
  279. } else if (
  280. shapesShortcutKeys.includes(event.key.toLowerCase()) &&
  281. !event.ctrlKey &&
  282. !event.shiftKey &&
  283. !event.altKey &&
  284. !event.metaKey &&
  285. this.state.draggingElement === null
  286. ) {
  287. if (shape === "text") {
  288. document.documentElement.style.cursor = CURSOR_TYPE.TEXT;
  289. } else {
  290. document.documentElement.style.cursor = CURSOR_TYPE.CROSSHAIR;
  291. }
  292. this.setState({ elementType: shape });
  293. } else if (event[KEYS.META] && event.code === "KeyZ") {
  294. event.preventDefault();
  295. if (event.shiftKey) {
  296. // Redo action
  297. const data = history.redoOnce();
  298. if (data !== null) {
  299. elements = data.elements;
  300. this.setState(data.appState);
  301. }
  302. } else {
  303. // undo action
  304. const data = history.undoOnce();
  305. if (data !== null) {
  306. elements = data.elements;
  307. this.setState(data.appState);
  308. }
  309. }
  310. }
  311. };
  312. private removeWheelEventListener: (() => void) | undefined;
  313. private copyToClipboard = () => {
  314. if (navigator.clipboard) {
  315. const text = JSON.stringify(
  316. elements
  317. .filter(element => element.isSelected)
  318. .map(({ shape, ...el }) => el),
  319. );
  320. navigator.clipboard.writeText(text);
  321. }
  322. };
  323. private pasteFromClipboard = () => {
  324. if (navigator.clipboard) {
  325. navigator.clipboard
  326. .readText()
  327. .then(text => this.addElementsFromPaste(text));
  328. }
  329. };
  330. private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) {
  331. const { t } = this.props;
  332. const { elementType, editingElement } = this.state;
  333. const selectedElements = elements.filter(el => el.isSelected);
  334. const hasSelectedElements = selectedElements.length > 0;
  335. const isTextToolSelected = elementType === "text";
  336. const isShapeToolSelected = elementType !== "selection";
  337. const isEditingText = editingElement && editingElement.type === "text";
  338. if (
  339. !hasSelectedElements &&
  340. !isShapeToolSelected &&
  341. !isTextToolSelected &&
  342. !isEditingText
  343. ) {
  344. return null;
  345. }
  346. return (
  347. <Island padding={4}>
  348. <div className="panelColumn">
  349. {this.actionManager.renderAction(
  350. "changeStrokeColor",
  351. elements,
  352. this.state,
  353. this.syncActionResult,
  354. t,
  355. )}
  356. {(hasBackground(elements) ||
  357. (isShapeToolSelected && !isTextToolSelected)) && (
  358. <>
  359. {this.actionManager.renderAction(
  360. "changeBackgroundColor",
  361. elements,
  362. this.state,
  363. this.syncActionResult,
  364. t,
  365. )}
  366. {this.actionManager.renderAction(
  367. "changeFillStyle",
  368. elements,
  369. this.state,
  370. this.syncActionResult,
  371. t,
  372. )}
  373. <hr />
  374. </>
  375. )}
  376. {(hasStroke(elements) ||
  377. (isShapeToolSelected && !isTextToolSelected)) && (
  378. <>
  379. {this.actionManager.renderAction(
  380. "changeStrokeWidth",
  381. elements,
  382. this.state,
  383. this.syncActionResult,
  384. t,
  385. )}
  386. {this.actionManager.renderAction(
  387. "changeSloppiness",
  388. elements,
  389. this.state,
  390. this.syncActionResult,
  391. t,
  392. )}
  393. <hr />
  394. </>
  395. )}
  396. {(hasText(elements) || isTextToolSelected || isEditingText) && (
  397. <>
  398. {this.actionManager.renderAction(
  399. "changeFontSize",
  400. elements,
  401. this.state,
  402. this.syncActionResult,
  403. t,
  404. )}
  405. {this.actionManager.renderAction(
  406. "changeFontFamily",
  407. elements,
  408. this.state,
  409. this.syncActionResult,
  410. t,
  411. )}
  412. <hr />
  413. </>
  414. )}
  415. {this.actionManager.renderAction(
  416. "changeOpacity",
  417. elements,
  418. this.state,
  419. this.syncActionResult,
  420. t,
  421. )}
  422. {this.actionManager.renderAction(
  423. "deleteSelectedElements",
  424. elements,
  425. this.state,
  426. this.syncActionResult,
  427. t,
  428. )}
  429. </div>
  430. </Island>
  431. );
  432. }
  433. private renderShapeLock() {
  434. const { elementLocked } = this.state;
  435. return (
  436. <LockIcon
  437. checked={elementLocked}
  438. onChange={() => this.setState({ elementLocked: !elementLocked })}
  439. />
  440. );
  441. }
  442. private renderShapesSwitcher() {
  443. const { t } = this.props;
  444. return (
  445. <>
  446. {SHAPES.map(({ value, icon }, index) => {
  447. const label = t(`toolBar.${value}`);
  448. return (
  449. <ToolButton
  450. key={value}
  451. type="radio"
  452. icon={icon}
  453. checked={this.state.elementType === value}
  454. name="editor-current-shape"
  455. title={`${capitalizeString(label)} — ${
  456. capitalizeString(value)[0]
  457. }, ${index + 1}`}
  458. aria-label={capitalizeString(label)}
  459. aria-keyshortcuts={`${label[0]} ${index + 1}`}
  460. onChange={() => {
  461. this.setState({ elementType: value });
  462. elements = clearSelection(elements);
  463. document.documentElement.style.cursor =
  464. value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
  465. this.forceUpdate();
  466. }}
  467. ></ToolButton>
  468. );
  469. })}
  470. {this.renderShapeLock()}
  471. </>
  472. );
  473. }
  474. private renderCanvasActions() {
  475. const { t } = this.props;
  476. return (
  477. <Stack.Col gap={4}>
  478. <Stack.Row justifyContent={"space-between"}>
  479. {this.actionManager.renderAction(
  480. "loadScene",
  481. elements,
  482. this.state,
  483. this.syncActionResult,
  484. t,
  485. )}
  486. {this.actionManager.renderAction(
  487. "saveScene",
  488. elements,
  489. this.state,
  490. this.syncActionResult,
  491. t,
  492. )}
  493. <ExportDialog
  494. elements={elements}
  495. appState={this.state}
  496. actionManager={this.actionManager}
  497. syncActionResult={this.syncActionResult}
  498. onExportToPng={(exportedElements, scale) => {
  499. if (this.canvas)
  500. exportCanvas("png", exportedElements, this.canvas, {
  501. exportBackground: this.state.exportBackground,
  502. name: this.state.name,
  503. viewBackgroundColor: this.state.viewBackgroundColor,
  504. scale,
  505. });
  506. }}
  507. onExportToClipboard={(exportedElements, scale) => {
  508. if (this.canvas)
  509. exportCanvas("clipboard", exportedElements, this.canvas, {
  510. exportBackground: this.state.exportBackground,
  511. name: this.state.name,
  512. viewBackgroundColor: this.state.viewBackgroundColor,
  513. scale,
  514. });
  515. }}
  516. onExportToBackend={exportedElements => {
  517. if (this.canvas)
  518. exportCanvas(
  519. "backend",
  520. exportedElements.map(element => ({
  521. ...element,
  522. isSelected: false,
  523. })),
  524. this.canvas,
  525. this.state,
  526. );
  527. }}
  528. />
  529. {this.actionManager.renderAction(
  530. "clearCanvas",
  531. elements,
  532. this.state,
  533. this.syncActionResult,
  534. t,
  535. )}
  536. </Stack.Row>
  537. {this.actionManager.renderAction(
  538. "changeViewBackgroundColor",
  539. elements,
  540. this.state,
  541. this.syncActionResult,
  542. t,
  543. )}
  544. </Stack.Col>
  545. );
  546. }
  547. public render() {
  548. const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
  549. const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
  550. const { t } = this.props;
  551. return (
  552. <div className="container">
  553. <FixedSideContainer side="top">
  554. <div className="App-menu App-menu_top">
  555. <Stack.Col gap={4} align="end">
  556. <div className="App-right-menu">
  557. <h2 className="visually-hidden">Canvas actions</h2>
  558. <Island padding={4}>{this.renderCanvasActions()}</Island>
  559. </div>
  560. <div className="App-right-menu">
  561. {this.renderSelectedShapeActions(elements)}
  562. </div>
  563. </Stack.Col>
  564. <Stack.Col gap={4} align="start">
  565. <Island padding={1}>
  566. <h2 className="visually-hidden">Shapes</h2>
  567. <Stack.Row gap={1}>{this.renderShapesSwitcher()}</Stack.Row>
  568. </Island>
  569. </Stack.Col>
  570. <div />
  571. </div>
  572. </FixedSideContainer>
  573. <canvas
  574. id="canvas"
  575. style={{
  576. width: canvasWidth,
  577. height: canvasHeight,
  578. }}
  579. width={canvasWidth * window.devicePixelRatio}
  580. height={canvasHeight * window.devicePixelRatio}
  581. ref={canvas => {
  582. if (this.canvas === null) {
  583. this.canvas = canvas;
  584. this.rc = rough.canvas(this.canvas!);
  585. }
  586. if (this.removeWheelEventListener) {
  587. this.removeWheelEventListener();
  588. this.removeWheelEventListener = undefined;
  589. }
  590. if (canvas) {
  591. canvas.addEventListener("wheel", this.handleWheel, {
  592. passive: false,
  593. });
  594. this.removeWheelEventListener = () =>
  595. canvas.removeEventListener("wheel", this.handleWheel);
  596. // Whenever React sets the width/height of the canvas element,
  597. // the context loses the scale transform. We need to re-apply it
  598. if (
  599. canvasWidth !== lastCanvasWidth ||
  600. canvasHeight !== lastCanvasHeight
  601. ) {
  602. lastCanvasWidth = canvasWidth;
  603. lastCanvasHeight = canvasHeight;
  604. canvas
  605. .getContext("2d")!
  606. .scale(window.devicePixelRatio, window.devicePixelRatio);
  607. }
  608. }
  609. }}
  610. onContextMenu={e => {
  611. e.preventDefault();
  612. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  613. const element = getElementAtPosition(elements, x, y);
  614. if (!element) {
  615. ContextMenu.push({
  616. options: [
  617. navigator.clipboard && {
  618. label: t("labels.paste"),
  619. action: () => this.pasteFromClipboard(),
  620. },
  621. ...this.actionManager.getContextMenuItems(
  622. elements,
  623. this.state,
  624. this.syncActionResult,
  625. action => this.canvasOnlyActions.includes(action),
  626. t,
  627. ),
  628. ],
  629. top: e.clientY,
  630. left: e.clientX,
  631. });
  632. return;
  633. }
  634. if (!element.isSelected) {
  635. elements = clearSelection(elements);
  636. element.isSelected = true;
  637. this.forceUpdate();
  638. }
  639. ContextMenu.push({
  640. options: [
  641. navigator.clipboard && {
  642. label: t("labels.copy"),
  643. action: this.copyToClipboard,
  644. },
  645. navigator.clipboard && {
  646. label: t("labels.paste"),
  647. action: () => this.pasteFromClipboard(),
  648. },
  649. ...this.actionManager.getContextMenuItems(
  650. elements,
  651. this.state,
  652. this.syncActionResult,
  653. action => !this.canvasOnlyActions.includes(action),
  654. t,
  655. ),
  656. ],
  657. top: e.clientY,
  658. left: e.clientX,
  659. });
  660. }}
  661. onMouseDown={e => {
  662. if (lastMouseUp !== null) {
  663. // Unfortunately, sometimes we don't get a mouseup after a mousedown,
  664. // this can happen when a contextual menu or alert is triggered. In order to avoid
  665. // being in a weird state, we clean up on the next mousedown
  666. lastMouseUp(e);
  667. }
  668. // pan canvas on wheel button drag
  669. if (e.button === 1) {
  670. let { clientX: lastX, clientY: lastY } = e;
  671. const onMouseMove = (e: MouseEvent) => {
  672. document.documentElement.style.cursor = `grabbing`;
  673. let deltaX = lastX - e.clientX;
  674. let deltaY = lastY - e.clientY;
  675. lastX = e.clientX;
  676. lastY = e.clientY;
  677. this.setState(state => ({
  678. scrollX: state.scrollX - deltaX,
  679. scrollY: state.scrollY - deltaY,
  680. }));
  681. };
  682. const onMouseUp = (lastMouseUp = (e: MouseEvent) => {
  683. lastMouseUp = null;
  684. resetCursor();
  685. window.removeEventListener("mousemove", onMouseMove);
  686. window.removeEventListener("mouseup", onMouseUp);
  687. });
  688. window.addEventListener("mousemove", onMouseMove, {
  689. passive: true,
  690. });
  691. window.addEventListener("mouseup", onMouseUp);
  692. return;
  693. }
  694. // only handle left mouse button
  695. if (e.button !== 0) return;
  696. // fixes mousemove causing selection of UI texts #32
  697. e.preventDefault();
  698. // Preventing the event above disables default behavior
  699. // of defocusing potentially focused input, which is what we want
  700. // when clicking inside the canvas.
  701. if (isInputLike(document.activeElement)) {
  702. document.activeElement.blur();
  703. }
  704. // Handle scrollbars dragging
  705. const {
  706. isOverHorizontalScrollBar,
  707. isOverVerticalScrollBar,
  708. } = isOverScrollBars(
  709. elements,
  710. e.clientX - CANVAS_WINDOW_OFFSET_LEFT,
  711. e.clientY - CANVAS_WINDOW_OFFSET_TOP,
  712. canvasWidth,
  713. canvasHeight,
  714. this.state.scrollX,
  715. this.state.scrollY,
  716. );
  717. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  718. const originX = x;
  719. const originY = y;
  720. let element = newElement(
  721. this.state.elementType,
  722. x,
  723. y,
  724. this.state.currentItemStrokeColor,
  725. this.state.currentItemBackgroundColor,
  726. this.state.currentItemFillStyle,
  727. this.state.currentItemStrokeWidth,
  728. this.state.currentItemRoughness,
  729. this.state.currentItemOpacity,
  730. );
  731. if (isTextElement(element)) {
  732. element = newTextElement(element, "", this.state.currentItemFont);
  733. }
  734. type ResizeTestType = ReturnType<typeof resizeTest>;
  735. let resizeHandle: ResizeTestType = false;
  736. let isResizingElements = false;
  737. let draggingOccurred = false;
  738. let hitElement: ExcalidrawElement | null = null;
  739. let elementIsAddedToSelection = false;
  740. if (this.state.elementType === "selection") {
  741. const resizeElement = getElementWithResizeHandler(
  742. elements,
  743. { x, y },
  744. this.state,
  745. );
  746. this.setState({
  747. resizingElement: resizeElement ? resizeElement.element : null,
  748. });
  749. if (resizeElement) {
  750. resizeHandle = resizeElement.resizeHandle;
  751. document.documentElement.style.cursor = getCursorForResizingElement(
  752. resizeElement,
  753. );
  754. isResizingElements = true;
  755. } else {
  756. hitElement = getElementAtPosition(elements, x, y);
  757. // clear selection if shift is not clicked
  758. if (!hitElement?.isSelected && !e.shiftKey) {
  759. elements = clearSelection(elements);
  760. }
  761. // If we click on something
  762. if (hitElement) {
  763. // deselect if item is selected
  764. // if shift is not clicked, this will always return true
  765. // otherwise, it will trigger selection based on current
  766. // state of the box
  767. if (!hitElement.isSelected) {
  768. hitElement.isSelected = true;
  769. elementIsAddedToSelection = true;
  770. }
  771. // We duplicate the selected element if alt is pressed on Mouse down
  772. if (e.altKey) {
  773. elements = [
  774. ...elements.map(element => ({
  775. ...element,
  776. isSelected: false,
  777. })),
  778. ...elements
  779. .filter(element => element.isSelected)
  780. .map(element => {
  781. const newElement = duplicateElement(element);
  782. newElement.isSelected = true;
  783. return newElement;
  784. }),
  785. ];
  786. }
  787. }
  788. }
  789. } else {
  790. elements = clearSelection(elements);
  791. }
  792. if (isTextElement(element)) {
  793. let textX = e.clientX;
  794. let textY = e.clientY;
  795. if (!e.altKey) {
  796. const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
  797. x,
  798. y,
  799. );
  800. if (snappedToCenterPosition) {
  801. element.x = snappedToCenterPosition.elementCenterX;
  802. element.y = snappedToCenterPosition.elementCenterY;
  803. textX = snappedToCenterPosition.wysiwygX;
  804. textY = snappedToCenterPosition.wysiwygY;
  805. }
  806. }
  807. const resetSelection = () => {
  808. this.setState({
  809. draggingElement: null,
  810. editingElement: null,
  811. elementType: "selection",
  812. });
  813. };
  814. textWysiwyg({
  815. initText: "",
  816. x: textX,
  817. y: textY,
  818. strokeColor: this.state.currentItemStrokeColor,
  819. opacity: this.state.currentItemOpacity,
  820. font: this.state.currentItemFont,
  821. onSubmit: text => {
  822. if (text) {
  823. elements = [
  824. ...elements,
  825. {
  826. ...newTextElement(
  827. element,
  828. text,
  829. this.state.currentItemFont,
  830. ),
  831. isSelected: true,
  832. },
  833. ];
  834. }
  835. resetSelection();
  836. },
  837. onCancel: () => {
  838. resetSelection();
  839. },
  840. });
  841. this.setState({
  842. elementType: "selection",
  843. editingElement: element,
  844. });
  845. return;
  846. }
  847. elements = [...elements, element];
  848. this.setState({ draggingElement: element });
  849. let lastX = x;
  850. let lastY = y;
  851. if (isOverHorizontalScrollBar || isOverVerticalScrollBar) {
  852. lastX = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
  853. lastY = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
  854. }
  855. const onMouseMove = (e: MouseEvent) => {
  856. const target = e.target;
  857. if (!(target instanceof HTMLElement)) {
  858. return;
  859. }
  860. if (isOverHorizontalScrollBar) {
  861. const x = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
  862. const dx = x - lastX;
  863. this.setState(state => ({ scrollX: state.scrollX - dx }));
  864. lastX = x;
  865. return;
  866. }
  867. if (isOverVerticalScrollBar) {
  868. const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
  869. const dy = y - lastY;
  870. this.setState(state => ({ scrollY: state.scrollY - dy }));
  871. lastY = y;
  872. return;
  873. }
  874. if (isResizingElements && this.state.resizingElement) {
  875. const el = this.state.resizingElement;
  876. const selectedElements = elements.filter(el => el.isSelected);
  877. if (selectedElements.length === 1) {
  878. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  879. const deltaX = x - lastX;
  880. const deltaY = y - lastY;
  881. const element = selectedElements[0];
  882. const isLinear =
  883. element.type === "line" || element.type === "arrow";
  884. switch (resizeHandle) {
  885. case "nw":
  886. element.width -= deltaX;
  887. element.x += deltaX;
  888. if (e.shiftKey) {
  889. if (isLinear) {
  890. resizePerfectLineForNWHandler(element, x, y);
  891. } else {
  892. element.y += element.height - element.width;
  893. element.height = element.width;
  894. }
  895. } else {
  896. element.height -= deltaY;
  897. element.y += deltaY;
  898. }
  899. break;
  900. case "ne":
  901. element.width += deltaX;
  902. if (e.shiftKey) {
  903. element.y += element.height - element.width;
  904. element.height = element.width;
  905. } else {
  906. element.height -= deltaY;
  907. element.y += deltaY;
  908. }
  909. break;
  910. case "sw":
  911. element.width -= deltaX;
  912. element.x += deltaX;
  913. if (e.shiftKey) {
  914. element.height = element.width;
  915. } else {
  916. element.height += deltaY;
  917. }
  918. break;
  919. case "se":
  920. if (e.shiftKey) {
  921. if (isLinear) {
  922. const { width, height } = getPerfectElementSize(
  923. element.type,
  924. x - element.x,
  925. y - element.y,
  926. );
  927. element.width = width;
  928. element.height = height;
  929. } else {
  930. element.width += deltaX;
  931. element.height = element.width;
  932. }
  933. } else {
  934. element.width += deltaX;
  935. element.height += deltaY;
  936. }
  937. break;
  938. case "n":
  939. element.height -= deltaY;
  940. element.y += deltaY;
  941. break;
  942. case "w":
  943. element.width -= deltaX;
  944. element.x += deltaX;
  945. break;
  946. case "s":
  947. element.height += deltaY;
  948. break;
  949. case "e":
  950. element.width += deltaX;
  951. break;
  952. }
  953. if (resizeHandle) {
  954. resizeHandle = normalizeResizeHandle(element, resizeHandle);
  955. }
  956. normalizeDimensions(element);
  957. document.documentElement.style.cursor = getCursorForResizingElement(
  958. { element, resizeHandle },
  959. );
  960. el.x = element.x;
  961. el.y = element.y;
  962. el.shape = null;
  963. lastX = x;
  964. lastY = y;
  965. // We don't want to save history when resizing an element
  966. history.skipRecording();
  967. this.forceUpdate();
  968. return;
  969. }
  970. }
  971. if (hitElement?.isSelected) {
  972. // Marking that click was used for dragging to check
  973. // if elements should be deselected on mouseup
  974. draggingOccurred = true;
  975. const selectedElements = elements.filter(el => el.isSelected);
  976. if (selectedElements.length) {
  977. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  978. selectedElements.forEach(element => {
  979. element.x += x - lastX;
  980. element.y += y - lastY;
  981. });
  982. lastX = x;
  983. lastY = y;
  984. // We don't want to save history when dragging an element to initially size it
  985. history.skipRecording();
  986. this.forceUpdate();
  987. return;
  988. }
  989. }
  990. // It is very important to read this.state within each move event,
  991. // otherwise we would read a stale one!
  992. const draggingElement = this.state.draggingElement;
  993. if (!draggingElement) return;
  994. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  995. let width = distance(originX, x);
  996. let height = distance(originY, y);
  997. const isLinear =
  998. this.state.elementType === "line" ||
  999. this.state.elementType === "arrow";
  1000. if (isLinear && x < originX) width = -width;
  1001. if (isLinear && y < originY) height = -height;
  1002. if (e.shiftKey) {
  1003. ({ width, height } = getPerfectElementSize(
  1004. this.state.elementType,
  1005. width,
  1006. !isLinear && y < originY ? -height : height,
  1007. ));
  1008. if (!isLinear && height < 0) height = -height;
  1009. }
  1010. if (!isLinear) {
  1011. draggingElement.x = x < originX ? originX - width : originX;
  1012. draggingElement.y = y < originY ? originY - height : originY;
  1013. }
  1014. draggingElement.width = width;
  1015. draggingElement.height = height;
  1016. draggingElement.shape = null;
  1017. if (this.state.elementType === "selection") {
  1018. if (!e.shiftKey) {
  1019. elements = clearSelection(elements);
  1020. }
  1021. const elementsWithinSelection = getElementsWithinSelection(
  1022. elements,
  1023. draggingElement,
  1024. );
  1025. elementsWithinSelection.forEach(element => {
  1026. element.isSelected = true;
  1027. });
  1028. }
  1029. // We don't want to save history when moving an element
  1030. history.skipRecording();
  1031. this.forceUpdate();
  1032. };
  1033. const onMouseUp = (e: MouseEvent) => {
  1034. const {
  1035. draggingElement,
  1036. resizingElement,
  1037. elementType,
  1038. elementLocked,
  1039. } = this.state;
  1040. lastMouseUp = null;
  1041. window.removeEventListener("mousemove", onMouseMove);
  1042. window.removeEventListener("mouseup", onMouseUp);
  1043. if (
  1044. elementType !== "selection" &&
  1045. draggingElement &&
  1046. isInvisiblySmallElement(draggingElement)
  1047. ) {
  1048. // remove invisible element which was added in onMouseDown
  1049. elements = elements.slice(0, -1);
  1050. this.setState({
  1051. draggingElement: null,
  1052. });
  1053. this.forceUpdate();
  1054. return;
  1055. }
  1056. if (normalizeDimensions(draggingElement)) {
  1057. this.forceUpdate();
  1058. }
  1059. if (resizingElement && isInvisiblySmallElement(resizingElement)) {
  1060. elements = elements.filter(el => el.id !== resizingElement.id);
  1061. }
  1062. // If click occurred on already selected element
  1063. // it is needed to remove selection from other elements
  1064. // or if SHIFT or META key pressed remove selection
  1065. // from hitted element
  1066. //
  1067. // If click occurred and elements were dragged or some element
  1068. // was added to selection (on mousedown phase) we need to keep
  1069. // selection unchanged
  1070. if (
  1071. hitElement &&
  1072. !draggingOccurred &&
  1073. !elementIsAddedToSelection
  1074. ) {
  1075. if (e.shiftKey) {
  1076. hitElement.isSelected = false;
  1077. } else {
  1078. elements = clearSelection(elements);
  1079. hitElement.isSelected = true;
  1080. }
  1081. }
  1082. if (draggingElement === null) {
  1083. // if no element is clicked, clear the selection and redraw
  1084. elements = clearSelection(elements);
  1085. this.forceUpdate();
  1086. return;
  1087. }
  1088. if (elementType === "selection") {
  1089. elements = elements.slice(0, -1);
  1090. } else if (!elementLocked) {
  1091. draggingElement.isSelected = true;
  1092. }
  1093. if (!elementLocked) {
  1094. resetCursor();
  1095. this.setState({
  1096. draggingElement: null,
  1097. elementType: "selection",
  1098. });
  1099. }
  1100. history.resumeRecording();
  1101. this.forceUpdate();
  1102. };
  1103. lastMouseUp = onMouseUp;
  1104. window.addEventListener("mousemove", onMouseMove);
  1105. window.addEventListener("mouseup", onMouseUp);
  1106. // We don't want to save history on mouseDown, only on mouseUp when it's fully configured
  1107. history.skipRecording();
  1108. this.forceUpdate();
  1109. }}
  1110. onDoubleClick={e => {
  1111. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1112. const elementAtPosition = getElementAtPosition(elements, x, y);
  1113. const element =
  1114. elementAtPosition && isTextElement(elementAtPosition)
  1115. ? elementAtPosition
  1116. : newTextElement(
  1117. newElement(
  1118. "text",
  1119. x,
  1120. y,
  1121. this.state.currentItemStrokeColor,
  1122. this.state.currentItemBackgroundColor,
  1123. this.state.currentItemFillStyle,
  1124. this.state.currentItemStrokeWidth,
  1125. this.state.currentItemRoughness,
  1126. this.state.currentItemOpacity,
  1127. ),
  1128. "", // default text
  1129. this.state.currentItemFont, // default font
  1130. );
  1131. this.setState({ editingElement: element });
  1132. let textX = e.clientX;
  1133. let textY = e.clientY;
  1134. if (elementAtPosition && isTextElement(elementAtPosition)) {
  1135. elements = elements.filter(
  1136. element => element.id !== elementAtPosition.id,
  1137. );
  1138. this.forceUpdate();
  1139. textX =
  1140. this.state.scrollX +
  1141. elementAtPosition.x +
  1142. CANVAS_WINDOW_OFFSET_LEFT +
  1143. elementAtPosition.width / 2;
  1144. textY =
  1145. this.state.scrollY +
  1146. elementAtPosition.y +
  1147. CANVAS_WINDOW_OFFSET_TOP +
  1148. elementAtPosition.height / 2;
  1149. // x and y will change after calling newTextElement function
  1150. element.x = elementAtPosition.x + elementAtPosition.width / 2;
  1151. element.y = elementAtPosition.y + elementAtPosition.height / 2;
  1152. } else if (!e.altKey) {
  1153. const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
  1154. x,
  1155. y,
  1156. );
  1157. if (snappedToCenterPosition) {
  1158. element.x = snappedToCenterPosition.elementCenterX;
  1159. element.y = snappedToCenterPosition.elementCenterY;
  1160. textX = snappedToCenterPosition.wysiwygX;
  1161. textY = snappedToCenterPosition.wysiwygY;
  1162. }
  1163. }
  1164. const resetSelection = () => {
  1165. this.setState({
  1166. draggingElement: null,
  1167. editingElement: null,
  1168. elementType: "selection",
  1169. });
  1170. };
  1171. textWysiwyg({
  1172. initText: element.text,
  1173. x: textX,
  1174. y: textY,
  1175. strokeColor: element.strokeColor,
  1176. font: element.font,
  1177. opacity: this.state.currentItemOpacity,
  1178. onSubmit: text => {
  1179. if (text) {
  1180. elements = [
  1181. ...elements,
  1182. {
  1183. // we need to recreate the element to update dimensions &
  1184. // position
  1185. ...newTextElement(element, text, element.font),
  1186. isSelected: true,
  1187. },
  1188. ];
  1189. }
  1190. resetSelection();
  1191. },
  1192. onCancel: () => {
  1193. resetSelection();
  1194. },
  1195. });
  1196. }}
  1197. onMouseMove={e => {
  1198. const hasDeselectedButton = Boolean(e.buttons);
  1199. if (hasDeselectedButton || this.state.elementType !== "selection") {
  1200. return;
  1201. }
  1202. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1203. const selectedElements = elements.filter(e => e.isSelected).length;
  1204. if (selectedElements === 1) {
  1205. const resizeElement = getElementWithResizeHandler(
  1206. elements,
  1207. { x, y },
  1208. this.state,
  1209. );
  1210. if (resizeElement && resizeElement.resizeHandle) {
  1211. document.documentElement.style.cursor = getCursorForResizingElement(
  1212. resizeElement,
  1213. );
  1214. return;
  1215. }
  1216. }
  1217. const hitElement = getElementAtPosition(elements, x, y);
  1218. document.documentElement.style.cursor = hitElement ? "move" : "";
  1219. }}
  1220. />
  1221. <LanguageList
  1222. onClick={lng => {
  1223. i18n.changeLanguage(lng);
  1224. }}
  1225. languages={languages}
  1226. currentLanguage={parseDetectedLang(i18n.language)}
  1227. />
  1228. </div>
  1229. );
  1230. }
  1231. private handleWheel = (e: WheelEvent) => {
  1232. e.preventDefault();
  1233. const { deltaX, deltaY } = e;
  1234. this.setState(state => ({
  1235. scrollX: state.scrollX - deltaX,
  1236. scrollY: state.scrollY - deltaY,
  1237. }));
  1238. };
  1239. private addElementsFromPaste = (paste: string) => {
  1240. let parsedElements;
  1241. try {
  1242. parsedElements = JSON.parse(paste);
  1243. } catch (e) {}
  1244. if (
  1245. Array.isArray(parsedElements) &&
  1246. parsedElements.length > 0 &&
  1247. parsedElements[0].type // need to implement a better check here...
  1248. ) {
  1249. elements = clearSelection(elements);
  1250. let subCanvasX1 = Infinity;
  1251. let subCanvasX2 = 0;
  1252. let subCanvasY1 = Infinity;
  1253. let subCanvasY2 = 0;
  1254. const minX = Math.min(...parsedElements.map(element => element.x));
  1255. const minY = Math.min(...parsedElements.map(element => element.y));
  1256. parsedElements.forEach(parsedElement => {
  1257. const [x1, y1, x2, y2] = getElementAbsoluteCoords(parsedElement);
  1258. subCanvasX1 = Math.min(subCanvasX1, x1);
  1259. subCanvasY1 = Math.min(subCanvasY1, y1);
  1260. subCanvasX2 = Math.max(subCanvasX2, x2);
  1261. subCanvasY2 = Math.max(subCanvasY2, y2);
  1262. });
  1263. const elementsCenterX = distance(subCanvasX1, subCanvasX2) / 2;
  1264. const elementsCenterY = distance(subCanvasY1, subCanvasY2) / 2;
  1265. const dx =
  1266. this.state.cursorX -
  1267. this.state.scrollX -
  1268. CANVAS_WINDOW_OFFSET_LEFT -
  1269. elementsCenterX;
  1270. const dy =
  1271. this.state.cursorY -
  1272. this.state.scrollY -
  1273. CANVAS_WINDOW_OFFSET_TOP -
  1274. elementsCenterY;
  1275. elements = [
  1276. ...elements,
  1277. ...parsedElements.map(parsedElement => {
  1278. const duplicate = duplicateElement(parsedElement);
  1279. duplicate.x += dx - minX;
  1280. duplicate.y += dy - minY;
  1281. return duplicate;
  1282. }),
  1283. ];
  1284. this.forceUpdate();
  1285. }
  1286. };
  1287. private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {
  1288. const elementClickedInside = getElementContainingPosition(elements, x, y);
  1289. if (elementClickedInside) {
  1290. const elementCenterX =
  1291. elementClickedInside.x + elementClickedInside.width / 2;
  1292. const elementCenterY =
  1293. elementClickedInside.y + elementClickedInside.height / 2;
  1294. const distanceToCenter = Math.hypot(
  1295. x - elementCenterX,
  1296. y - elementCenterY,
  1297. );
  1298. const isSnappedToCenter =
  1299. distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
  1300. if (isSnappedToCenter) {
  1301. const wysiwygX =
  1302. this.state.scrollX +
  1303. elementClickedInside.x +
  1304. CANVAS_WINDOW_OFFSET_LEFT +
  1305. elementClickedInside.width / 2;
  1306. const wysiwygY =
  1307. this.state.scrollY +
  1308. elementClickedInside.y +
  1309. CANVAS_WINDOW_OFFSET_TOP +
  1310. elementClickedInside.height / 2;
  1311. return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
  1312. }
  1313. }
  1314. }
  1315. private saveDebounced = debounce(() => {
  1316. saveToLocalStorage(elements, this.state);
  1317. }, 300);
  1318. componentDidUpdate() {
  1319. renderScene(elements, this.rc!, this.canvas!, {
  1320. scrollX: this.state.scrollX,
  1321. scrollY: this.state.scrollY,
  1322. viewBackgroundColor: this.state.viewBackgroundColor,
  1323. });
  1324. this.saveDebounced();
  1325. if (history.isRecording()) {
  1326. history.pushEntry(
  1327. history.generateCurrentEntry(
  1328. pickAppStatePropertiesForHistory(this.state),
  1329. elements,
  1330. ),
  1331. );
  1332. }
  1333. }
  1334. }
  1335. const AppWithTrans = withTranslation()(App);
  1336. const rootElement = document.getElementById("root");
  1337. class TopErrorBoundary extends React.Component {
  1338. state = { hasError: false, stack: "", localStorage: "" };
  1339. static getDerivedStateFromError(error: any) {
  1340. console.error(error);
  1341. return {
  1342. hasError: true,
  1343. localStorage: JSON.stringify({ ...localStorage }),
  1344. stack: error.stack,
  1345. };
  1346. }
  1347. private selectTextArea(event: React.MouseEvent<HTMLTextAreaElement>) {
  1348. (event.target as HTMLTextAreaElement).select();
  1349. }
  1350. private async createGithubIssue() {
  1351. let body = "";
  1352. try {
  1353. const templateStr = (await import("./bug-issue-template")).default;
  1354. if (typeof templateStr === "string") {
  1355. body = encodeURIComponent(templateStr);
  1356. }
  1357. } catch {}
  1358. window.open(
  1359. `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
  1360. );
  1361. }
  1362. render() {
  1363. if (this.state.hasError) {
  1364. return (
  1365. <div className="ErrorSplash">
  1366. <div className="ErrorSplash-messageContainer">
  1367. <div className="ErrorSplash-paragraph bigger">
  1368. Encountered an error. Please{" "}
  1369. <button onClick={() => window.location.reload()}>
  1370. reload the page
  1371. </button>
  1372. .
  1373. </div>
  1374. <div className="ErrorSplash-paragraph">
  1375. If reloading doesn't work. Try{" "}
  1376. <button
  1377. onClick={() => {
  1378. localStorage.clear();
  1379. window.location.reload();
  1380. }}
  1381. >
  1382. clearing the canvas
  1383. </button>
  1384. .<br />
  1385. <div className="smaller">
  1386. (This will unfortunately result in loss of work.)
  1387. </div>
  1388. </div>
  1389. <div>
  1390. <div className="ErrorSplash-paragraph">
  1391. Before doing so, we'd appreciate if you opened an issue on our{" "}
  1392. <button onClick={this.createGithubIssue}>bug tracker</button>.
  1393. Please include the following error stack trace & localStorage
  1394. content (provided it's not private):
  1395. </div>
  1396. <div className="ErrorSplash-paragraph">
  1397. <div className="ErrorSplash-details">
  1398. <label>Error stack trace:</label>
  1399. <textarea
  1400. rows={10}
  1401. onClick={this.selectTextArea}
  1402. defaultValue={this.state.stack}
  1403. />
  1404. <label>LocalStorage content:</label>
  1405. <textarea
  1406. rows={5}
  1407. onClick={this.selectTextArea}
  1408. defaultValue={this.state.localStorage}
  1409. />
  1410. </div>
  1411. </div>
  1412. </div>
  1413. </div>
  1414. </div>
  1415. );
  1416. }
  1417. return this.props.children;
  1418. }
  1419. }
  1420. ReactDOM.render(
  1421. <TopErrorBoundary>
  1422. <AppWithTrans />
  1423. </TopErrorBoundary>,
  1424. rootElement,
  1425. );