index.tsx 42 KB

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