index.tsx 54 KB

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