index.tsx 51 KB

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