index.tsx 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069
  1. import React from "react";
  2. import ReactDOM from "react-dom";
  3. import rough from "roughjs/bin/wrappers/rough";
  4. import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex";
  5. import { randomSeed } from "./random";
  6. import { newElement, resizeTest, isTextElement, textWysiwyg } from "./element";
  7. import {
  8. clearSelection,
  9. getSelectedIndices,
  10. deleteSelectedElements,
  11. setSelection,
  12. isOverScrollBars,
  13. someElementIsSelected,
  14. getSelectedAttribute,
  15. loadFromJSON,
  16. saveAsJSON,
  17. exportAsPNG,
  18. restoreFromLocalStorage,
  19. saveToLocalStorage,
  20. hasBackground,
  21. hasStroke,
  22. getElementAtPosition,
  23. createScene
  24. } from "./scene";
  25. import { renderScene } from "./renderer";
  26. import { AppState } from "./types";
  27. import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types";
  28. import { getDateTime, isInputLike } from "./utils";
  29. import { ButtonSelect } from "./components/ButtonSelect";
  30. import { findShapeByKey, shapesShortcutKeys } from "./shapes";
  31. import { createHistory } from "./history";
  32. import "./styles.scss";
  33. import ContextMenu from "./components/ContextMenu";
  34. import { PanelTools } from "./components/panels/PanelTools";
  35. import { PanelSelection } from "./components/panels/PanelSelection";
  36. import { PanelColor } from "./components/panels/PanelColor";
  37. import { PanelExport } from "./components/panels/PanelExport";
  38. import { PanelCanvas } from "./components/panels/PanelCanvas";
  39. const { elements } = createScene();
  40. const { history } = createHistory();
  41. const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
  42. const CANVAS_WINDOW_OFFSET_LEFT = 250;
  43. const CANVAS_WINDOW_OFFSET_TOP = 0;
  44. export const KEYS = {
  45. ARROW_LEFT: "ArrowLeft",
  46. ARROW_RIGHT: "ArrowRight",
  47. ARROW_DOWN: "ArrowDown",
  48. ARROW_UP: "ArrowUp",
  49. ENTER: "Enter",
  50. ESCAPE: "Escape",
  51. DELETE: "Delete",
  52. BACKSPACE: "Backspace"
  53. };
  54. const META_KEY = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform)
  55. ? "metaKey"
  56. : "ctrlKey";
  57. let copiedStyles: string = "{}";
  58. function isArrowKey(keyCode: string) {
  59. return (
  60. keyCode === KEYS.ARROW_LEFT ||
  61. keyCode === KEYS.ARROW_RIGHT ||
  62. keyCode === KEYS.ARROW_DOWN ||
  63. keyCode === KEYS.ARROW_UP
  64. );
  65. }
  66. function resetCursor() {
  67. document.documentElement.style.cursor = "";
  68. }
  69. function addTextElement(
  70. element: ExcalidrawTextElement,
  71. text: string,
  72. font: string
  73. ) {
  74. resetCursor();
  75. if (text === null || text === "") {
  76. return false;
  77. }
  78. element.text = text;
  79. element.font = font;
  80. const currentFont = context.font;
  81. context.font = element.font;
  82. const textMeasure = context.measureText(element.text);
  83. const width = textMeasure.width;
  84. const actualBoundingBoxAscent =
  85. textMeasure.actualBoundingBoxAscent || parseInt(font);
  86. const actualBoundingBoxDescent = textMeasure.actualBoundingBoxDescent || 0;
  87. element.actualBoundingBoxAscent = actualBoundingBoxAscent;
  88. context.font = currentFont;
  89. const height = actualBoundingBoxAscent + actualBoundingBoxDescent;
  90. // Center the text
  91. element.x -= width / 2;
  92. element.y -= actualBoundingBoxAscent;
  93. element.width = width;
  94. element.height = height;
  95. return true;
  96. }
  97. const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
  98. const ELEMENT_TRANSLATE_AMOUNT = 1;
  99. let lastCanvasWidth = -1;
  100. let lastCanvasHeight = -1;
  101. let lastMouseUp: ((e: any) => void) | null = null;
  102. class App extends React.Component<{}, AppState> {
  103. public componentDidMount() {
  104. document.addEventListener("keydown", this.onKeyDown, false);
  105. window.addEventListener("resize", this.onResize, false);
  106. const savedState = restoreFromLocalStorage(elements);
  107. if (savedState) {
  108. this.setState(savedState);
  109. }
  110. }
  111. public componentWillUnmount() {
  112. document.removeEventListener("keydown", this.onKeyDown, false);
  113. window.removeEventListener("resize", this.onResize, false);
  114. }
  115. public state: AppState = {
  116. draggingElement: null,
  117. resizingElement: null,
  118. elementType: "selection",
  119. exportBackground: true,
  120. currentItemStrokeColor: "#000000",
  121. currentItemBackgroundColor: "#ffffff",
  122. currentItemFont: "20px Virgil",
  123. viewBackgroundColor: "#ffffff",
  124. scrollX: 0,
  125. scrollY: 0,
  126. name: DEFAULT_PROJECT_NAME
  127. };
  128. private onResize = () => {
  129. this.forceUpdate();
  130. };
  131. private onKeyDown = (event: KeyboardEvent) => {
  132. if (isInputLike(event.target)) return;
  133. if (event.key === KEYS.ESCAPE) {
  134. clearSelection(elements);
  135. this.forceUpdate();
  136. event.preventDefault();
  137. } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) {
  138. this.deleteSelectedElements();
  139. event.preventDefault();
  140. } else if (isArrowKey(event.key)) {
  141. const step = event.shiftKey
  142. ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
  143. : ELEMENT_TRANSLATE_AMOUNT;
  144. elements.forEach(element => {
  145. if (element.isSelected) {
  146. if (event.key === KEYS.ARROW_LEFT) element.x -= step;
  147. else if (event.key === KEYS.ARROW_RIGHT) element.x += step;
  148. else if (event.key === KEYS.ARROW_UP) element.y -= step;
  149. else if (event.key === KEYS.ARROW_DOWN) element.y += step;
  150. }
  151. });
  152. this.forceUpdate();
  153. event.preventDefault();
  154. // Send backward: Cmd-Shift-Alt-B
  155. } else if (
  156. event[META_KEY] &&
  157. event.shiftKey &&
  158. event.altKey &&
  159. event.code === "KeyB"
  160. ) {
  161. this.moveOneLeft();
  162. event.preventDefault();
  163. // Send to back: Cmd-Shift-B
  164. } else if (event[META_KEY] && event.shiftKey && event.code === "KeyB") {
  165. this.moveAllLeft();
  166. event.preventDefault();
  167. // Bring forward: Cmd-Shift-Alt-F
  168. } else if (
  169. event[META_KEY] &&
  170. event.shiftKey &&
  171. event.altKey &&
  172. event.code === "KeyF"
  173. ) {
  174. this.moveOneRight();
  175. event.preventDefault();
  176. // Bring to front: Cmd-Shift-F
  177. } else if (event[META_KEY] && event.shiftKey && event.code === "KeyF") {
  178. this.moveAllRight();
  179. event.preventDefault();
  180. // Select all: Cmd-A
  181. } else if (event[META_KEY] && event.code === "KeyA") {
  182. elements.forEach(element => {
  183. element.isSelected = true;
  184. });
  185. this.forceUpdate();
  186. event.preventDefault();
  187. } else if (shapesShortcutKeys.includes(event.key.toLowerCase())) {
  188. this.setState({ elementType: findShapeByKey(event.key) });
  189. } else if (event[META_KEY] && event.code === "KeyZ") {
  190. if (event.shiftKey) {
  191. // Redo action
  192. history.redoOnce(elements);
  193. } else {
  194. // undo action
  195. history.undoOnce(elements);
  196. }
  197. this.forceUpdate();
  198. event.preventDefault();
  199. // Copy Styles: Cmd-Shift-C
  200. } else if (event.metaKey && event.shiftKey && event.code === "KeyC") {
  201. this.copyStyles();
  202. // Paste Styles: Cmd-Shift-V
  203. } else if (event.metaKey && event.shiftKey && event.code === "KeyV") {
  204. this.pasteStyles();
  205. event.preventDefault();
  206. }
  207. };
  208. private deleteSelectedElements = () => {
  209. deleteSelectedElements(elements);
  210. this.forceUpdate();
  211. };
  212. private clearCanvas = () => {
  213. if (window.confirm("This will clear the whole canvas. Are you sure?")) {
  214. elements.splice(0, elements.length);
  215. this.setState({
  216. viewBackgroundColor: "#ffffff",
  217. scrollX: 0,
  218. scrollY: 0
  219. });
  220. this.forceUpdate();
  221. }
  222. };
  223. private copyStyles = () => {
  224. const element = elements.find(el => el.isSelected);
  225. if (element) {
  226. copiedStyles = JSON.stringify(element);
  227. }
  228. };
  229. private pasteStyles = () => {
  230. const pastedElement = JSON.parse(copiedStyles);
  231. elements.forEach(element => {
  232. if (element.isSelected) {
  233. element.backgroundColor = pastedElement?.backgroundColor;
  234. element.strokeWidth = pastedElement?.strokeWidth;
  235. element.strokeColor = pastedElement?.strokeColor;
  236. element.fillStyle = pastedElement?.fillStyle;
  237. element.opacity = pastedElement?.opacity;
  238. element.roughness = pastedElement?.roughness;
  239. }
  240. });
  241. this.forceUpdate();
  242. };
  243. private moveAllLeft = () => {
  244. moveAllLeft(elements, getSelectedIndices(elements));
  245. this.forceUpdate();
  246. };
  247. private moveOneLeft = () => {
  248. moveOneLeft(elements, getSelectedIndices(elements));
  249. this.forceUpdate();
  250. };
  251. private moveAllRight = () => {
  252. moveAllRight(elements, getSelectedIndices(elements));
  253. this.forceUpdate();
  254. };
  255. private moveOneRight = () => {
  256. moveOneRight(elements, getSelectedIndices(elements));
  257. this.forceUpdate();
  258. };
  259. private removeWheelEventListener: (() => void) | undefined;
  260. private updateProjectName(name: string): void {
  261. this.setState({ name });
  262. }
  263. private changeProperty = (callback: (element: ExcalidrawElement) => void) => {
  264. elements.forEach(element => {
  265. if (element.isSelected) {
  266. callback(element);
  267. }
  268. });
  269. this.forceUpdate();
  270. };
  271. private changeOpacity = (event: React.ChangeEvent<HTMLInputElement>) => {
  272. this.changeProperty(element => (element.opacity = +event.target.value));
  273. };
  274. private changeStrokeColor = (color: string) => {
  275. this.changeProperty(element => (element.strokeColor = color));
  276. this.setState({ currentItemStrokeColor: color });
  277. };
  278. private changeBackgroundColor = (color: string) => {
  279. this.changeProperty(element => (element.backgroundColor = color));
  280. this.setState({ currentItemBackgroundColor: color });
  281. };
  282. private copyToClipboard = () => {
  283. if (navigator.clipboard) {
  284. const text = JSON.stringify(
  285. elements.filter(element => element.isSelected)
  286. );
  287. navigator.clipboard.writeText(text);
  288. }
  289. };
  290. private pasteFromClipboard = (x?: number, y?: number) => {
  291. if (navigator.clipboard) {
  292. navigator.clipboard
  293. .readText()
  294. .then(text => this.addElementsFromPaste(text, x, y));
  295. }
  296. };
  297. public render() {
  298. const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
  299. const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
  300. return (
  301. <div
  302. className="container"
  303. onCut={e => {
  304. e.clipboardData.setData(
  305. "text/plain",
  306. JSON.stringify(elements.filter(element => element.isSelected))
  307. );
  308. deleteSelectedElements(elements);
  309. this.forceUpdate();
  310. e.preventDefault();
  311. }}
  312. onCopy={e => {
  313. e.clipboardData.setData(
  314. "text/plain",
  315. JSON.stringify(elements.filter(element => element.isSelected))
  316. );
  317. e.preventDefault();
  318. }}
  319. onPaste={e => {
  320. const paste = e.clipboardData.getData("text");
  321. this.addElementsFromPaste(paste);
  322. e.preventDefault();
  323. }}
  324. >
  325. <div className="sidePanel">
  326. <PanelTools
  327. activeTool={this.state.elementType}
  328. onToolChange={value => {
  329. this.setState({ elementType: value });
  330. clearSelection(elements);
  331. document.documentElement.style.cursor =
  332. value === "text" ? "text" : "crosshair";
  333. this.forceUpdate();
  334. }}
  335. />
  336. {someElementIsSelected(elements) && (
  337. <div className="panelColumn">
  338. <PanelSelection
  339. onBringForward={this.moveOneRight}
  340. onBringToFront={this.moveAllRight}
  341. onSendBackward={this.moveOneLeft}
  342. onSendToBack={this.moveAllLeft}
  343. />
  344. <PanelColor
  345. title="Stroke Color"
  346. onColorChange={this.changeStrokeColor}
  347. colorValue={getSelectedAttribute(
  348. elements,
  349. element => element.strokeColor
  350. )}
  351. />
  352. {hasBackground(elements) && (
  353. <>
  354. <PanelColor
  355. title="Background Color"
  356. onColorChange={this.changeBackgroundColor}
  357. colorValue={getSelectedAttribute(
  358. elements,
  359. element => element.backgroundColor
  360. )}
  361. />
  362. <h5>Fill</h5>
  363. <ButtonSelect
  364. options={[
  365. { value: "solid", text: "Solid" },
  366. { value: "hachure", text: "Hachure" },
  367. { value: "cross-hatch", text: "Cross-hatch" }
  368. ]}
  369. value={getSelectedAttribute(
  370. elements,
  371. element => element.fillStyle
  372. )}
  373. onChange={value => {
  374. this.changeProperty(element => {
  375. element.fillStyle = value;
  376. });
  377. }}
  378. />
  379. </>
  380. )}
  381. {hasStroke(elements) && (
  382. <>
  383. <h5>Stroke Width</h5>
  384. <ButtonSelect
  385. options={[
  386. { value: 1, text: "Thin" },
  387. { value: 2, text: "Bold" },
  388. { value: 4, text: "Extra Bold" }
  389. ]}
  390. value={getSelectedAttribute(
  391. elements,
  392. element => element.strokeWidth
  393. )}
  394. onChange={value => {
  395. this.changeProperty(element => {
  396. element.strokeWidth = value;
  397. });
  398. }}
  399. />
  400. <h5>Sloppiness</h5>
  401. <ButtonSelect
  402. options={[
  403. { value: 0, text: "Draftsman" },
  404. { value: 1, text: "Artist" },
  405. { value: 3, text: "Cartoonist" }
  406. ]}
  407. value={getSelectedAttribute(
  408. elements,
  409. element => element.roughness
  410. )}
  411. onChange={value =>
  412. this.changeProperty(element => {
  413. element.roughness = value;
  414. })
  415. }
  416. />
  417. </>
  418. )}
  419. <h5>Opacity</h5>
  420. <input
  421. type="range"
  422. min="0"
  423. max="100"
  424. onChange={this.changeOpacity}
  425. value={
  426. getSelectedAttribute(elements, element => element.opacity) ||
  427. 0 /* Put the opacity at 0 if there are two conflicting ones */
  428. }
  429. />
  430. <button onClick={this.deleteSelectedElements}>
  431. Delete selected
  432. </button>
  433. </div>
  434. )}
  435. <PanelCanvas
  436. onClearCanvas={this.clearCanvas}
  437. onViewBackgroundColorChange={val =>
  438. this.setState({ viewBackgroundColor: val })
  439. }
  440. viewBackgroundColor={this.state.viewBackgroundColor}
  441. />
  442. <PanelExport
  443. projectName={this.state.name}
  444. onProjectNameChange={this.updateProjectName}
  445. onExportAsPNG={() => exportAsPNG(elements, canvas, this.state)}
  446. exportBackground={this.state.exportBackground}
  447. onExportBackgroundChange={val =>
  448. this.setState({ exportBackground: val })
  449. }
  450. onSaveScene={() => saveAsJSON(elements, this.state.name)}
  451. onLoadScene={() =>
  452. loadFromJSON(elements).then(() => this.forceUpdate())
  453. }
  454. />
  455. </div>
  456. <canvas
  457. id="canvas"
  458. style={{
  459. width: canvasWidth,
  460. height: canvasHeight
  461. }}
  462. width={canvasWidth * window.devicePixelRatio}
  463. height={canvasHeight * window.devicePixelRatio}
  464. ref={canvas => {
  465. if (this.removeWheelEventListener) {
  466. this.removeWheelEventListener();
  467. this.removeWheelEventListener = undefined;
  468. }
  469. if (canvas) {
  470. canvas.addEventListener("wheel", this.handleWheel, {
  471. passive: false
  472. });
  473. this.removeWheelEventListener = () =>
  474. canvas.removeEventListener("wheel", this.handleWheel);
  475. // Whenever React sets the width/height of the canvas element,
  476. // the context loses the scale transform. We need to re-apply it
  477. if (
  478. canvasWidth !== lastCanvasWidth ||
  479. canvasHeight !== lastCanvasHeight
  480. ) {
  481. lastCanvasWidth = canvasWidth;
  482. lastCanvasHeight = canvasHeight;
  483. canvas
  484. .getContext("2d")!
  485. .scale(window.devicePixelRatio, window.devicePixelRatio);
  486. }
  487. }
  488. }}
  489. onContextMenu={e => {
  490. e.preventDefault();
  491. const x =
  492. e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
  493. const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
  494. const element = getElementAtPosition(elements, x, y);
  495. if (!element) {
  496. ContextMenu.push({
  497. options: [
  498. navigator.clipboard && {
  499. label: "Paste",
  500. action: () => this.pasteFromClipboard(x, y)
  501. }
  502. ],
  503. top: e.clientY,
  504. left: e.clientX
  505. });
  506. return;
  507. }
  508. if (!element.isSelected) {
  509. clearSelection(elements);
  510. element.isSelected = true;
  511. this.forceUpdate();
  512. }
  513. ContextMenu.push({
  514. options: [
  515. navigator.clipboard && {
  516. label: "Copy",
  517. action: this.copyToClipboard
  518. },
  519. navigator.clipboard && {
  520. label: "Paste",
  521. action: () => this.pasteFromClipboard(x, y)
  522. },
  523. { label: "Copy Styles", action: this.copyStyles },
  524. { label: "Paste Styles", action: this.pasteStyles },
  525. { label: "Delete", action: this.deleteSelectedElements },
  526. { label: "Move Forward", action: this.moveOneRight },
  527. { label: "Send to Front", action: this.moveAllRight },
  528. { label: "Move Backwards", action: this.moveOneLeft },
  529. { label: "Send to Back", action: this.moveAllLeft }
  530. ],
  531. top: e.clientY,
  532. left: e.clientX
  533. });
  534. }}
  535. onMouseDown={e => {
  536. if (lastMouseUp !== null) {
  537. // Unfortunately, sometimes we don't get a mouseup after a mousedown,
  538. // this can happen when a contextual menu or alert is triggered. In order to avoid
  539. // being in a weird state, we clean up on the next mousedown
  540. lastMouseUp(e);
  541. }
  542. // only handle left mouse button
  543. if (e.button !== 0) return;
  544. // fixes mousemove causing selection of UI texts #32
  545. e.preventDefault();
  546. // Preventing the event above disables default behavior
  547. // of defocusing potentially focused input, which is what we want
  548. // when clicking inside the canvas.
  549. if (isInputLike(document.activeElement)) {
  550. document.activeElement.blur();
  551. }
  552. // Handle scrollbars dragging
  553. const {
  554. isOverHorizontalScrollBar,
  555. isOverVerticalScrollBar
  556. } = isOverScrollBars(
  557. elements,
  558. e.clientX - CANVAS_WINDOW_OFFSET_LEFT,
  559. e.clientY - CANVAS_WINDOW_OFFSET_TOP,
  560. canvasWidth,
  561. canvasHeight,
  562. this.state.scrollX,
  563. this.state.scrollY
  564. );
  565. const x =
  566. e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
  567. const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
  568. const element = newElement(
  569. this.state.elementType,
  570. x,
  571. y,
  572. this.state.currentItemStrokeColor,
  573. this.state.currentItemBackgroundColor,
  574. "hachure",
  575. 1,
  576. 1,
  577. 100
  578. );
  579. let resizeHandle: string | false = false;
  580. let isDraggingElements = false;
  581. let isResizingElements = false;
  582. if (this.state.elementType === "selection") {
  583. const resizeElement = elements.find(element => {
  584. return resizeTest(element, x, y, {
  585. scrollX: this.state.scrollX,
  586. scrollY: this.state.scrollY,
  587. viewBackgroundColor: this.state.viewBackgroundColor
  588. });
  589. });
  590. this.setState({
  591. resizingElement: resizeElement ? resizeElement : null
  592. });
  593. if (resizeElement) {
  594. resizeHandle = resizeTest(resizeElement, x, y, {
  595. scrollX: this.state.scrollX,
  596. scrollY: this.state.scrollY,
  597. viewBackgroundColor: this.state.viewBackgroundColor
  598. });
  599. document.documentElement.style.cursor = `${resizeHandle}-resize`;
  600. isResizingElements = true;
  601. } else {
  602. const hitElement = getElementAtPosition(elements, x, y);
  603. // If we click on something
  604. if (hitElement) {
  605. if (hitElement.isSelected) {
  606. // If that element is not already selected, do nothing,
  607. // we're likely going to drag it
  608. } else {
  609. // We unselect every other elements unless shift is pressed
  610. if (!e.shiftKey) {
  611. clearSelection(elements);
  612. }
  613. }
  614. // No matter what, we select it
  615. hitElement.isSelected = true;
  616. // We duplicate the selected element if alt is pressed on Mouse down
  617. if (e.altKey) {
  618. const element = newElement(
  619. hitElement.type,
  620. hitElement.x,
  621. hitElement.y,
  622. hitElement.strokeColor,
  623. hitElement.backgroundColor,
  624. hitElement.fillStyle,
  625. hitElement.strokeWidth,
  626. hitElement.roughness,
  627. hitElement.opacity,
  628. hitElement.width,
  629. hitElement.height
  630. );
  631. elements.push(element);
  632. }
  633. } else {
  634. // If we don't click on anything, let's remove all the selected elements
  635. clearSelection(elements);
  636. }
  637. isDraggingElements = someElementIsSelected(elements);
  638. if (isDraggingElements) {
  639. document.documentElement.style.cursor = "move";
  640. }
  641. }
  642. }
  643. if (isTextElement(element)) {
  644. textWysiwyg({
  645. initText: "",
  646. x: e.clientX,
  647. y: e.clientY,
  648. strokeColor: this.state.currentItemStrokeColor,
  649. font: this.state.currentItemFont,
  650. onSubmit: text => {
  651. addTextElement(element, text, this.state.currentItemFont);
  652. elements.push(element);
  653. element.isSelected = true;
  654. this.setState({
  655. draggingElement: null,
  656. elementType: "selection"
  657. });
  658. }
  659. });
  660. return;
  661. }
  662. elements.push(element);
  663. if (this.state.elementType === "text") {
  664. this.setState({
  665. draggingElement: null,
  666. elementType: "selection"
  667. });
  668. element.isSelected = true;
  669. } else {
  670. this.setState({ draggingElement: element });
  671. }
  672. let lastX = x;
  673. let lastY = y;
  674. if (isOverHorizontalScrollBar || isOverVerticalScrollBar) {
  675. lastX = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
  676. lastY = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
  677. }
  678. const onMouseMove = (e: MouseEvent) => {
  679. const target = e.target;
  680. if (!(target instanceof HTMLElement)) {
  681. return;
  682. }
  683. if (isOverHorizontalScrollBar) {
  684. const x = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
  685. const dx = x - lastX;
  686. this.setState(state => ({ scrollX: state.scrollX - dx }));
  687. lastX = x;
  688. return;
  689. }
  690. if (isOverVerticalScrollBar) {
  691. const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
  692. const dy = y - lastY;
  693. this.setState(state => ({ scrollY: state.scrollY - dy }));
  694. lastY = y;
  695. return;
  696. }
  697. if (isResizingElements && this.state.resizingElement) {
  698. const el = this.state.resizingElement;
  699. const selectedElements = elements.filter(el => el.isSelected);
  700. if (selectedElements.length === 1) {
  701. const x =
  702. e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
  703. const y =
  704. e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
  705. selectedElements.forEach(element => {
  706. switch (resizeHandle) {
  707. case "nw":
  708. element.width += element.x - lastX;
  709. element.x = lastX;
  710. if (e.shiftKey) {
  711. element.y += element.height - element.width;
  712. element.height = element.width;
  713. } else {
  714. element.height += element.y - lastY;
  715. element.y = lastY;
  716. }
  717. break;
  718. case "ne":
  719. element.width = lastX - element.x;
  720. if (e.shiftKey) {
  721. element.y += element.height - element.width;
  722. element.height = element.width;
  723. } else {
  724. element.height += element.y - lastY;
  725. element.y = lastY;
  726. }
  727. break;
  728. case "sw":
  729. element.width += element.x - lastX;
  730. element.x = lastX;
  731. if (e.shiftKey) {
  732. element.height = element.width;
  733. } else {
  734. element.height = lastY - element.y;
  735. }
  736. break;
  737. case "se":
  738. element.width += x - lastX;
  739. if (e.shiftKey) {
  740. element.height = element.width;
  741. } else {
  742. element.height += y - lastY;
  743. }
  744. break;
  745. case "n":
  746. element.height += element.y - lastY;
  747. element.y = lastY;
  748. break;
  749. case "w":
  750. element.width += element.x - lastX;
  751. element.x = lastX;
  752. break;
  753. case "s":
  754. element.height = lastY - element.y;
  755. break;
  756. case "e":
  757. element.width = lastX - element.x;
  758. break;
  759. }
  760. el.x = element.x;
  761. el.y = element.y;
  762. });
  763. lastX = x;
  764. lastY = y;
  765. // We don't want to save history when resizing an element
  766. history.skipRecording();
  767. this.forceUpdate();
  768. return;
  769. }
  770. }
  771. if (isDraggingElements) {
  772. const selectedElements = elements.filter(el => el.isSelected);
  773. if (selectedElements.length) {
  774. const x =
  775. e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
  776. const y =
  777. e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
  778. selectedElements.forEach(element => {
  779. element.x += x - lastX;
  780. element.y += y - lastY;
  781. });
  782. lastX = x;
  783. lastY = y;
  784. // We don't want to save history when dragging an element to initially size it
  785. history.skipRecording();
  786. this.forceUpdate();
  787. return;
  788. }
  789. }
  790. // It is very important to read this.state within each move event,
  791. // otherwise we would read a stale one!
  792. const draggingElement = this.state.draggingElement;
  793. if (!draggingElement) return;
  794. let width =
  795. e.clientX -
  796. CANVAS_WINDOW_OFFSET_LEFT -
  797. draggingElement.x -
  798. this.state.scrollX;
  799. let height =
  800. e.clientY -
  801. CANVAS_WINDOW_OFFSET_TOP -
  802. draggingElement.y -
  803. this.state.scrollY;
  804. draggingElement.width = width;
  805. // Make a perfect square or circle when shift is enabled
  806. draggingElement.height = e.shiftKey
  807. ? Math.abs(width) * Math.sign(height)
  808. : height;
  809. if (this.state.elementType === "selection") {
  810. setSelection(elements, draggingElement);
  811. }
  812. // We don't want to save history when moving an element
  813. history.skipRecording();
  814. this.forceUpdate();
  815. };
  816. const onMouseUp = (e: MouseEvent) => {
  817. const { draggingElement, elementType } = this.state;
  818. lastMouseUp = null;
  819. window.removeEventListener("mousemove", onMouseMove);
  820. window.removeEventListener("mouseup", onMouseUp);
  821. resetCursor();
  822. // if no element is clicked, clear the selection and redraw
  823. if (draggingElement === null) {
  824. clearSelection(elements);
  825. this.forceUpdate();
  826. return;
  827. }
  828. if (elementType === "selection") {
  829. if (isDraggingElements) {
  830. isDraggingElements = false;
  831. }
  832. elements.pop();
  833. } else {
  834. draggingElement.isSelected = true;
  835. }
  836. this.setState({
  837. draggingElement: null,
  838. elementType: "selection"
  839. });
  840. this.forceUpdate();
  841. };
  842. lastMouseUp = onMouseUp;
  843. window.addEventListener("mousemove", onMouseMove);
  844. window.addEventListener("mouseup", onMouseUp);
  845. // We don't want to save history on mouseDown, only on mouseUp when it's fully configured
  846. history.skipRecording();
  847. this.forceUpdate();
  848. }}
  849. onDoubleClick={e => {
  850. const x =
  851. e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
  852. const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
  853. const elementAtPosition = getElementAtPosition(elements, x, y);
  854. if (elementAtPosition && !isTextElement(elementAtPosition)) {
  855. return;
  856. } else if (elementAtPosition) {
  857. elements.splice(elements.indexOf(elementAtPosition), 1);
  858. this.forceUpdate();
  859. }
  860. const element = newElement(
  861. "text",
  862. x,
  863. y,
  864. this.state.currentItemStrokeColor,
  865. this.state.currentItemBackgroundColor,
  866. "hachure",
  867. 1,
  868. 1,
  869. 100
  870. ) as ExcalidrawTextElement;
  871. let initText = "";
  872. let textX = e.clientX;
  873. let textY = e.clientY;
  874. if (elementAtPosition) {
  875. Object.assign(element, elementAtPosition);
  876. // x and y will change after calling addTextElement function
  877. element.x = elementAtPosition.x + elementAtPosition.width / 2;
  878. element.y =
  879. elementAtPosition.y + elementAtPosition.actualBoundingBoxAscent;
  880. initText = elementAtPosition.text;
  881. textX =
  882. this.state.scrollX +
  883. elementAtPosition.x +
  884. CANVAS_WINDOW_OFFSET_LEFT +
  885. elementAtPosition.width / 2;
  886. textY =
  887. this.state.scrollY +
  888. elementAtPosition.y +
  889. CANVAS_WINDOW_OFFSET_TOP +
  890. elementAtPosition.actualBoundingBoxAscent;
  891. }
  892. textWysiwyg({
  893. initText,
  894. x: textX,
  895. y: textY,
  896. strokeColor: element.strokeColor,
  897. font: element.font || this.state.currentItemFont,
  898. onSubmit: text => {
  899. addTextElement(
  900. element,
  901. text,
  902. element.font || this.state.currentItemFont
  903. );
  904. elements.push(element);
  905. element.isSelected = true;
  906. this.setState({
  907. draggingElement: null,
  908. elementType: "selection"
  909. });
  910. }
  911. });
  912. }}
  913. />
  914. </div>
  915. );
  916. }
  917. private handleWheel = (e: WheelEvent) => {
  918. e.preventDefault();
  919. const { deltaX, deltaY } = e;
  920. this.setState(state => ({
  921. scrollX: state.scrollX - deltaX,
  922. scrollY: state.scrollY - deltaY
  923. }));
  924. };
  925. private addElementsFromPaste = (paste: string, x?: number, y?: number) => {
  926. let parsedElements;
  927. try {
  928. parsedElements = JSON.parse(paste);
  929. } catch (e) {}
  930. if (
  931. Array.isArray(parsedElements) &&
  932. parsedElements.length > 0 &&
  933. parsedElements[0].type // need to implement a better check here...
  934. ) {
  935. clearSelection(elements);
  936. if (x == null) x = 10 - this.state.scrollX;
  937. if (y == null) y = 10 - this.state.scrollY;
  938. const minX = Math.min(...parsedElements.map(element => element.x));
  939. const minY = Math.min(...parsedElements.map(element => element.y));
  940. const dx = x - minX;
  941. const dy = y - minY;
  942. parsedElements.forEach(parsedElement => {
  943. parsedElement.x += dx;
  944. parsedElement.y += dy;
  945. parsedElement.seed = randomSeed();
  946. elements.push(parsedElement);
  947. });
  948. this.forceUpdate();
  949. }
  950. };
  951. componentDidUpdate() {
  952. renderScene(elements, rc, canvas, {
  953. scrollX: this.state.scrollX,
  954. scrollY: this.state.scrollY,
  955. viewBackgroundColor: this.state.viewBackgroundColor
  956. });
  957. saveToLocalStorage(elements, this.state);
  958. if (history.isRecording()) {
  959. history.pushEntry(history.generateCurrentEntry(elements));
  960. history.clearRedoStack();
  961. }
  962. history.resumeRecording();
  963. }
  964. }
  965. const rootElement = document.getElementById("root");
  966. ReactDOM.render(<App />, rootElement);
  967. const canvas = document.getElementById("canvas") as HTMLCanvasElement;
  968. const rc = rough.canvas(canvas);
  969. const context = canvas.getContext("2d")!;
  970. ReactDOM.render(<App />, rootElement);