index.tsx 33 KB

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