index.tsx 42 KB

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