index.tsx 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286
  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. ) {
  261. this.setState({ elementType: findShapeByKey(event.key) });
  262. } else if (event[META_KEY] && event.code === "KeyZ") {
  263. if (event.shiftKey) {
  264. // Redo action
  265. const data = history.redoOnce();
  266. if (data !== null) {
  267. elements = data;
  268. }
  269. } else {
  270. // undo action
  271. const data = history.undoOnce();
  272. if (data !== null) {
  273. elements = data;
  274. }
  275. }
  276. this.forceUpdate();
  277. event.preventDefault();
  278. }
  279. };
  280. private removeWheelEventListener: (() => void) | undefined;
  281. private copyToClipboard = () => {
  282. if (navigator.clipboard) {
  283. const text = JSON.stringify(
  284. elements
  285. .filter(element => element.isSelected)
  286. .map(({ shape, ...el }) => el)
  287. );
  288. navigator.clipboard.writeText(text);
  289. }
  290. };
  291. private pasteFromClipboard = () => {
  292. if (navigator.clipboard) {
  293. navigator.clipboard
  294. .readText()
  295. .then(text => this.addElementsFromPaste(text));
  296. }
  297. };
  298. private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) {
  299. const selectedElements = elements.filter(el => el.isSelected);
  300. if (selectedElements.length === 0) {
  301. return null;
  302. }
  303. return (
  304. <Island padding={4}>
  305. <div className="panelColumn">
  306. {this.actionManager.renderAction(
  307. "changeStrokeColor",
  308. elements,
  309. this.state,
  310. this.syncActionResult
  311. )}
  312. {hasBackground(elements) && (
  313. <>
  314. {this.actionManager.renderAction(
  315. "changeBackgroundColor",
  316. elements,
  317. this.state,
  318. this.syncActionResult
  319. )}
  320. {this.actionManager.renderAction(
  321. "changeFillStyle",
  322. elements,
  323. this.state,
  324. this.syncActionResult
  325. )}
  326. <hr />
  327. </>
  328. )}
  329. {hasStroke(elements) && (
  330. <>
  331. {this.actionManager.renderAction(
  332. "changeStrokeWidth",
  333. elements,
  334. this.state,
  335. this.syncActionResult
  336. )}
  337. {this.actionManager.renderAction(
  338. "changeSloppiness",
  339. elements,
  340. this.state,
  341. this.syncActionResult
  342. )}
  343. <hr />
  344. </>
  345. )}
  346. {hasText(elements) && (
  347. <>
  348. {this.actionManager.renderAction(
  349. "changeFontSize",
  350. elements,
  351. this.state,
  352. this.syncActionResult
  353. )}
  354. {this.actionManager.renderAction(
  355. "changeFontFamily",
  356. elements,
  357. this.state,
  358. this.syncActionResult
  359. )}
  360. <hr />
  361. </>
  362. )}
  363. {this.actionManager.renderAction(
  364. "changeOpacity",
  365. elements,
  366. this.state,
  367. this.syncActionResult
  368. )}
  369. {this.actionManager.renderAction(
  370. "deleteSelectedElements",
  371. elements,
  372. this.state,
  373. this.syncActionResult
  374. )}
  375. </div>
  376. </Island>
  377. );
  378. }
  379. private renderShapesSwitcher() {
  380. return (
  381. <>
  382. {SHAPES.map(({ value, icon }, index) => (
  383. <ToolIcon
  384. key={value}
  385. type="radio"
  386. icon={icon}
  387. checked={this.state.elementType === value}
  388. name="editor-current-shape"
  389. title={`${capitalizeString(value)} — ${
  390. capitalizeString(value)[0]
  391. }, ${index + 1}`}
  392. onChange={() => {
  393. this.setState({ elementType: value });
  394. elements = clearSelection(elements);
  395. document.documentElement.style.cursor =
  396. value === "text" ? "text" : "crosshair";
  397. this.forceUpdate();
  398. }}
  399. ></ToolIcon>
  400. ))}
  401. </>
  402. );
  403. }
  404. private renderCanvasActions() {
  405. return (
  406. <Stack.Col gap={4}>
  407. <Stack.Row justifyContent={"space-between"}>
  408. {this.actionManager.renderAction(
  409. "loadScene",
  410. elements,
  411. this.state,
  412. this.syncActionResult
  413. )}
  414. {this.actionManager.renderAction(
  415. "saveScene",
  416. elements,
  417. this.state,
  418. this.syncActionResult
  419. )}
  420. <ExportDialog
  421. elements={elements}
  422. appState={this.state}
  423. actionManager={this.actionManager}
  424. syncActionResult={this.syncActionResult}
  425. onExportToPng={(exportedElements, scale) => {
  426. if (this.canvas)
  427. exportCanvas("png", exportedElements, this.canvas, {
  428. exportBackground: this.state.exportBackground,
  429. name: this.state.name,
  430. viewBackgroundColor: this.state.viewBackgroundColor,
  431. scale
  432. });
  433. }}
  434. onExportToClipboard={(exportedElements, scale) => {
  435. if (this.canvas)
  436. exportCanvas("clipboard", exportedElements, this.canvas, {
  437. exportBackground: this.state.exportBackground,
  438. name: this.state.name,
  439. viewBackgroundColor: this.state.viewBackgroundColor,
  440. scale
  441. });
  442. }}
  443. />
  444. {this.actionManager.renderAction(
  445. "clearCanvas",
  446. elements,
  447. this.state,
  448. this.syncActionResult
  449. )}
  450. </Stack.Row>
  451. {this.actionManager.renderAction(
  452. "changeViewBackgroundColor",
  453. elements,
  454. this.state,
  455. this.syncActionResult
  456. )}
  457. </Stack.Col>
  458. );
  459. }
  460. public render() {
  461. const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
  462. const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
  463. return (
  464. <div className="container">
  465. <FixedSideContainer side="top">
  466. <div className="App-menu App-menu_top">
  467. <Stack.Col gap={4} align="end">
  468. <div className="App-right-menu">
  469. <Island padding={4}>{this.renderCanvasActions()}</Island>
  470. </div>
  471. <div className="App-right-menu">
  472. {this.renderSelectedShapeActions(elements)}
  473. </div>
  474. </Stack.Col>
  475. <Stack.Col gap={4} align="start">
  476. <Island padding={1}>
  477. <Stack.Row gap={1}>{this.renderShapesSwitcher()}</Stack.Row>
  478. </Island>
  479. </Stack.Col>
  480. <div />
  481. </div>
  482. </FixedSideContainer>
  483. <canvas
  484. id="canvas"
  485. style={{
  486. width: canvasWidth,
  487. height: canvasHeight
  488. }}
  489. width={canvasWidth * window.devicePixelRatio}
  490. height={canvasHeight * window.devicePixelRatio}
  491. ref={canvas => {
  492. if (this.canvas === null) {
  493. this.canvas = canvas;
  494. this.rc = rough.canvas(this.canvas!);
  495. }
  496. if (this.removeWheelEventListener) {
  497. this.removeWheelEventListener();
  498. this.removeWheelEventListener = undefined;
  499. }
  500. if (canvas) {
  501. canvas.addEventListener("wheel", this.handleWheel, {
  502. passive: false
  503. });
  504. this.removeWheelEventListener = () =>
  505. canvas.removeEventListener("wheel", this.handleWheel);
  506. // Whenever React sets the width/height of the canvas element,
  507. // the context loses the scale transform. We need to re-apply it
  508. if (
  509. canvasWidth !== lastCanvasWidth ||
  510. canvasHeight !== lastCanvasHeight
  511. ) {
  512. lastCanvasWidth = canvasWidth;
  513. lastCanvasHeight = canvasHeight;
  514. canvas
  515. .getContext("2d")!
  516. .scale(window.devicePixelRatio, window.devicePixelRatio);
  517. }
  518. }
  519. }}
  520. onContextMenu={e => {
  521. e.preventDefault();
  522. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  523. const element = getElementAtPosition(elements, x, y);
  524. if (!element) {
  525. ContextMenu.push({
  526. options: [
  527. navigator.clipboard && {
  528. label: "Paste",
  529. action: () => this.pasteFromClipboard()
  530. },
  531. ...this.actionManager.getContextMenuItems(
  532. elements,
  533. this.state,
  534. this.syncActionResult,
  535. action => this.canvasOnlyActions.includes(action)
  536. )
  537. ],
  538. top: e.clientY,
  539. left: e.clientX
  540. });
  541. return;
  542. }
  543. if (!element.isSelected) {
  544. elements = clearSelection(elements);
  545. element.isSelected = true;
  546. this.forceUpdate();
  547. }
  548. ContextMenu.push({
  549. options: [
  550. navigator.clipboard && {
  551. label: "Copy",
  552. action: this.copyToClipboard
  553. },
  554. navigator.clipboard && {
  555. label: "Paste",
  556. action: () => this.pasteFromClipboard()
  557. },
  558. ...this.actionManager.getContextMenuItems(
  559. elements,
  560. this.state,
  561. this.syncActionResult,
  562. action => !this.canvasOnlyActions.includes(action)
  563. )
  564. ],
  565. top: e.clientY,
  566. left: e.clientX
  567. });
  568. }}
  569. onMouseDown={e => {
  570. if (lastMouseUp !== null) {
  571. // Unfortunately, sometimes we don't get a mouseup after a mousedown,
  572. // this can happen when a contextual menu or alert is triggered. In order to avoid
  573. // being in a weird state, we clean up on the next mousedown
  574. lastMouseUp(e);
  575. }
  576. // pan canvas on wheel button drag
  577. if (e.button === 1) {
  578. let { clientX: lastX, clientY: lastY } = e;
  579. const onMouseMove = (e: MouseEvent) => {
  580. document.documentElement.style.cursor = `grabbing`;
  581. let deltaX = lastX - e.clientX;
  582. let deltaY = lastY - e.clientY;
  583. lastX = e.clientX;
  584. lastY = e.clientY;
  585. this.setState(state => ({
  586. scrollX: state.scrollX - deltaX,
  587. scrollY: state.scrollY - deltaY
  588. }));
  589. };
  590. const onMouseUp = (lastMouseUp = (e: MouseEvent) => {
  591. lastMouseUp = null;
  592. resetCursor();
  593. window.removeEventListener("mousemove", onMouseMove);
  594. window.removeEventListener("mouseup", onMouseUp);
  595. });
  596. window.addEventListener("mousemove", onMouseMove, {
  597. passive: true
  598. });
  599. window.addEventListener("mouseup", onMouseUp);
  600. return;
  601. }
  602. // only handle left mouse button
  603. if (e.button !== 0) return;
  604. // fixes mousemove causing selection of UI texts #32
  605. e.preventDefault();
  606. // Preventing the event above disables default behavior
  607. // of defocusing potentially focused input, which is what we want
  608. // when clicking inside the canvas.
  609. if (isInputLike(document.activeElement)) {
  610. document.activeElement.blur();
  611. }
  612. // Handle scrollbars dragging
  613. const {
  614. isOverHorizontalScrollBar,
  615. isOverVerticalScrollBar
  616. } = isOverScrollBars(
  617. elements,
  618. e.clientX - CANVAS_WINDOW_OFFSET_LEFT,
  619. e.clientY - CANVAS_WINDOW_OFFSET_TOP,
  620. canvasWidth,
  621. canvasHeight,
  622. this.state.scrollX,
  623. this.state.scrollY
  624. );
  625. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  626. const element = newElement(
  627. this.state.elementType,
  628. x,
  629. y,
  630. this.state.currentItemStrokeColor,
  631. this.state.currentItemBackgroundColor,
  632. "hachure",
  633. 1,
  634. 1,
  635. 100
  636. );
  637. type ResizeTestType = ReturnType<typeof resizeTest>;
  638. let resizeHandle: ResizeTestType = false;
  639. let isResizingElements = false;
  640. let draggingOccured = false;
  641. let hitElement: ExcalidrawElement | null = null;
  642. let elementIsAddedToSelection = false;
  643. if (this.state.elementType === "selection") {
  644. const resizeElement = getElementWithResizeHandler(
  645. elements,
  646. { x, y },
  647. this.state
  648. );
  649. this.setState({
  650. resizingElement: resizeElement ? resizeElement.element : null
  651. });
  652. if (resizeElement) {
  653. resizeHandle = resizeElement.resizeHandle;
  654. document.documentElement.style.cursor = `${resizeHandle}-resize`;
  655. isResizingElements = true;
  656. } else {
  657. hitElement = getElementAtPosition(elements, x, y);
  658. // clear selection if shift is not clicked
  659. if (!hitElement?.isSelected && !e.shiftKey) {
  660. elements = clearSelection(elements);
  661. }
  662. // If we click on something
  663. if (hitElement) {
  664. // deselect if item is selected
  665. // if shift is not clicked, this will always return true
  666. // otherwise, it will trigger selection based on current
  667. // state of the box
  668. if (!hitElement.isSelected) {
  669. hitElement.isSelected = true;
  670. elementIsAddedToSelection = true;
  671. }
  672. // We duplicate the selected element if alt is pressed on Mouse down
  673. if (e.altKey) {
  674. elements = [
  675. ...elements.map(element => ({
  676. ...element,
  677. isSelected: false
  678. })),
  679. ...elements
  680. .filter(element => element.isSelected)
  681. .map(element => {
  682. const newElement = duplicateElement(element);
  683. newElement.isSelected = true;
  684. return newElement;
  685. })
  686. ];
  687. }
  688. }
  689. }
  690. } else {
  691. elements = clearSelection(elements);
  692. }
  693. if (isTextElement(element)) {
  694. let textX = e.clientX;
  695. let textY = e.clientY;
  696. if (!e.altKey) {
  697. const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
  698. x,
  699. y
  700. );
  701. if (snappedToCenterPosition) {
  702. element.x = snappedToCenterPosition.elementCenterX;
  703. element.y = snappedToCenterPosition.elementCenterY;
  704. textX = snappedToCenterPosition.wysiwygX;
  705. textY = snappedToCenterPosition.wysiwygY;
  706. }
  707. }
  708. textWysiwyg({
  709. initText: "",
  710. x: textX,
  711. y: textY,
  712. strokeColor: this.state.currentItemStrokeColor,
  713. font: this.state.currentItemFont,
  714. onSubmit: text => {
  715. addTextElement(element, text, this.state.currentItemFont);
  716. elements = [...elements, { ...element, isSelected: true }];
  717. this.setState({
  718. draggingElement: null,
  719. elementType: "selection"
  720. });
  721. }
  722. });
  723. this.setState({ elementType: "selection" });
  724. return;
  725. }
  726. if (this.state.elementType === "text") {
  727. elements = [...elements, { ...element, isSelected: true }];
  728. this.setState({
  729. draggingElement: null,
  730. elementType: "selection"
  731. });
  732. } else {
  733. elements = [...elements, element];
  734. this.setState({ draggingElement: element });
  735. }
  736. let lastX = x;
  737. let lastY = y;
  738. if (isOverHorizontalScrollBar || isOverVerticalScrollBar) {
  739. lastX = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
  740. lastY = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
  741. }
  742. const onMouseMove = (e: MouseEvent) => {
  743. const target = e.target;
  744. if (!(target instanceof HTMLElement)) {
  745. return;
  746. }
  747. if (isOverHorizontalScrollBar) {
  748. const x = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
  749. const dx = x - lastX;
  750. this.setState(state => ({ scrollX: state.scrollX - dx }));
  751. lastX = x;
  752. return;
  753. }
  754. if (isOverVerticalScrollBar) {
  755. const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
  756. const dy = y - lastY;
  757. this.setState(state => ({ scrollY: state.scrollY - dy }));
  758. lastY = y;
  759. return;
  760. }
  761. if (isResizingElements && this.state.resizingElement) {
  762. const el = this.state.resizingElement;
  763. const selectedElements = elements.filter(el => el.isSelected);
  764. if (selectedElements.length === 1) {
  765. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  766. let deltaX = 0;
  767. let deltaY = 0;
  768. selectedElements.forEach(element => {
  769. switch (resizeHandle) {
  770. case "nw":
  771. deltaX = lastX - x;
  772. element.width += deltaX;
  773. element.x -= deltaX;
  774. if (e.shiftKey) {
  775. element.y += element.height - element.width;
  776. element.height = element.width;
  777. } else {
  778. const deltaY = lastY - y;
  779. element.height += deltaY;
  780. element.y -= deltaY;
  781. }
  782. break;
  783. case "ne":
  784. element.width += x - lastX;
  785. if (e.shiftKey) {
  786. element.y += element.height - element.width;
  787. element.height = element.width;
  788. } else {
  789. deltaY = lastY - y;
  790. element.height += deltaY;
  791. element.y -= deltaY;
  792. }
  793. break;
  794. case "sw":
  795. deltaX = lastX - x;
  796. element.width += deltaX;
  797. element.x -= deltaX;
  798. if (e.shiftKey) {
  799. element.height = element.width;
  800. } else {
  801. element.height += y - lastY;
  802. }
  803. break;
  804. case "se":
  805. element.width += x - lastX;
  806. if (e.shiftKey) {
  807. element.height = element.width;
  808. } else {
  809. element.height += y - lastY;
  810. }
  811. break;
  812. case "n":
  813. deltaY = lastY - y;
  814. element.height += deltaY;
  815. element.y -= deltaY;
  816. break;
  817. case "w":
  818. deltaX = lastX - x;
  819. element.width += deltaX;
  820. element.x -= deltaX;
  821. break;
  822. case "s":
  823. element.height += y - lastY;
  824. break;
  825. case "e":
  826. element.width += x - lastX;
  827. break;
  828. }
  829. el.x = element.x;
  830. el.y = element.y;
  831. el.shape = null;
  832. });
  833. lastX = x;
  834. lastY = y;
  835. // We don't want to save history when resizing an element
  836. history.skipRecording();
  837. this.forceUpdate();
  838. return;
  839. }
  840. }
  841. if (hitElement?.isSelected) {
  842. // Marking that click was used for dragging to check
  843. // if elements should be deselected on mouseup
  844. draggingOccured = true;
  845. const selectedElements = elements.filter(el => el.isSelected);
  846. if (selectedElements.length) {
  847. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  848. selectedElements.forEach(element => {
  849. element.x += x - lastX;
  850. element.y += y - lastY;
  851. });
  852. lastX = x;
  853. lastY = y;
  854. // We don't want to save history when dragging an element to initially size it
  855. history.skipRecording();
  856. this.forceUpdate();
  857. return;
  858. }
  859. }
  860. // It is very important to read this.state within each move event,
  861. // otherwise we would read a stale one!
  862. const draggingElement = this.state.draggingElement;
  863. if (!draggingElement) return;
  864. let width =
  865. e.clientX -
  866. CANVAS_WINDOW_OFFSET_LEFT -
  867. draggingElement.x -
  868. this.state.scrollX;
  869. let height =
  870. e.clientY -
  871. CANVAS_WINDOW_OFFSET_TOP -
  872. draggingElement.y -
  873. this.state.scrollY;
  874. draggingElement.width = width;
  875. // Make a perfect square or circle when shift is enabled
  876. draggingElement.height =
  877. e.shiftKey && this.state.elementType !== "selection"
  878. ? Math.abs(width) * Math.sign(height)
  879. : height;
  880. draggingElement.shape = null;
  881. if (this.state.elementType === "selection") {
  882. if (!e.shiftKey) {
  883. elements = clearSelection(elements);
  884. }
  885. const elementsWithinSelection = getElementsWithinSelection(
  886. elements,
  887. draggingElement
  888. );
  889. elementsWithinSelection.forEach(element => {
  890. element.isSelected = true;
  891. });
  892. }
  893. // We don't want to save history when moving an element
  894. history.skipRecording();
  895. this.forceUpdate();
  896. };
  897. const onMouseUp = (e: MouseEvent) => {
  898. const {
  899. draggingElement,
  900. resizingElement,
  901. elementType
  902. } = this.state;
  903. lastMouseUp = null;
  904. window.removeEventListener("mousemove", onMouseMove);
  905. window.removeEventListener("mouseup", onMouseUp);
  906. if (
  907. elementType !== "selection" &&
  908. draggingElement &&
  909. isInvisiblySmallElement(draggingElement)
  910. ) {
  911. // remove invisible element which was added in onMouseDown
  912. elements = elements.slice(0, -1);
  913. this.setState({
  914. draggingElement: null
  915. });
  916. this.forceUpdate();
  917. return;
  918. }
  919. if (resizingElement && isInvisiblySmallElement(resizingElement)) {
  920. elements = elements.filter(el => el.id !== resizingElement.id);
  921. }
  922. resetCursor();
  923. // If click occured on already selected element
  924. // it is needed to remove selection from other elements
  925. // or if SHIFT or META key pressed remove selection
  926. // from hitted element
  927. //
  928. // If click occured and elements were dragged or some element
  929. // was added to selection (on mousedown phase) we need to keep
  930. // selection unchanged
  931. if (
  932. hitElement &&
  933. !draggingOccured &&
  934. !elementIsAddedToSelection
  935. ) {
  936. if (e.shiftKey) {
  937. hitElement.isSelected = false;
  938. } else {
  939. elements = clearSelection(elements);
  940. hitElement.isSelected = true;
  941. }
  942. }
  943. if (draggingElement === null) {
  944. // if no element is clicked, clear the selection and redraw
  945. elements = clearSelection(elements);
  946. this.forceUpdate();
  947. return;
  948. }
  949. if (elementType === "selection") {
  950. elements = elements.slice(0, -1);
  951. } else {
  952. draggingElement.isSelected = true;
  953. }
  954. this.setState({
  955. draggingElement: null,
  956. elementType: "selection"
  957. });
  958. history.resumeRecording();
  959. this.forceUpdate();
  960. };
  961. lastMouseUp = onMouseUp;
  962. window.addEventListener("mousemove", onMouseMove);
  963. window.addEventListener("mouseup", onMouseUp);
  964. // We don't want to save history on mouseDown, only on mouseUp when it's fully configured
  965. history.skipRecording();
  966. this.forceUpdate();
  967. }}
  968. onDoubleClick={e => {
  969. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  970. const elementAtPosition = getElementAtPosition(elements, x, y);
  971. const element = newElement(
  972. "text",
  973. x,
  974. y,
  975. this.state.currentItemStrokeColor,
  976. this.state.currentItemBackgroundColor,
  977. "hachure",
  978. 1,
  979. 1,
  980. 100
  981. ) as ExcalidrawTextElement;
  982. let initText = "";
  983. let textX = e.clientX;
  984. let textY = e.clientY;
  985. if (elementAtPosition && isTextElement(elementAtPosition)) {
  986. elements = elements.filter(
  987. element => element.id !== elementAtPosition.id
  988. );
  989. this.forceUpdate();
  990. Object.assign(element, elementAtPosition);
  991. // x and y will change after calling addTextElement function
  992. element.x = elementAtPosition.x + elementAtPosition.width / 2;
  993. element.y = elementAtPosition.y + elementAtPosition.height / 2;
  994. initText = elementAtPosition.text;
  995. textX =
  996. this.state.scrollX +
  997. elementAtPosition.x +
  998. CANVAS_WINDOW_OFFSET_LEFT +
  999. elementAtPosition.width / 2;
  1000. textY =
  1001. this.state.scrollY +
  1002. elementAtPosition.y +
  1003. CANVAS_WINDOW_OFFSET_TOP +
  1004. elementAtPosition.height / 2;
  1005. } else if (!e.altKey) {
  1006. const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
  1007. x,
  1008. y
  1009. );
  1010. if (snappedToCenterPosition) {
  1011. element.x = snappedToCenterPosition.elementCenterX;
  1012. element.y = snappedToCenterPosition.elementCenterY;
  1013. textX = snappedToCenterPosition.wysiwygX;
  1014. textY = snappedToCenterPosition.wysiwygY;
  1015. }
  1016. }
  1017. textWysiwyg({
  1018. initText,
  1019. x: textX,
  1020. y: textY,
  1021. strokeColor: element.strokeColor,
  1022. font: element.font || this.state.currentItemFont,
  1023. onSubmit: text => {
  1024. addTextElement(
  1025. element,
  1026. text,
  1027. element.font || this.state.currentItemFont
  1028. );
  1029. elements = [...elements, { ...element, isSelected: true }];
  1030. this.setState({
  1031. draggingElement: null,
  1032. elementType: "selection"
  1033. });
  1034. }
  1035. });
  1036. }}
  1037. onMouseMove={e => {
  1038. const hasDeselectedButton = Boolean(e.buttons);
  1039. if (hasDeselectedButton || this.state.elementType !== "selection") {
  1040. return;
  1041. }
  1042. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1043. const selectedElements = elements.filter(e => e.isSelected).length;
  1044. if (selectedElements === 1) {
  1045. const resizeElement = getElementWithResizeHandler(
  1046. elements,
  1047. { x, y },
  1048. this.state
  1049. );
  1050. if (resizeElement && resizeElement.resizeHandle) {
  1051. document.documentElement.style.cursor = `${resizeElement.resizeHandle}-resize`;
  1052. return;
  1053. }
  1054. }
  1055. const hitElement = getElementAtPosition(elements, x, y);
  1056. document.documentElement.style.cursor = hitElement ? "move" : "";
  1057. }}
  1058. />
  1059. </div>
  1060. );
  1061. }
  1062. private handleWheel = (e: WheelEvent) => {
  1063. e.preventDefault();
  1064. const { deltaX, deltaY } = e;
  1065. this.setState(state => ({
  1066. scrollX: state.scrollX - deltaX,
  1067. scrollY: state.scrollY - deltaY
  1068. }));
  1069. };
  1070. private addElementsFromPaste = (paste: string) => {
  1071. let parsedElements;
  1072. try {
  1073. parsedElements = JSON.parse(paste);
  1074. } catch (e) {}
  1075. if (
  1076. Array.isArray(parsedElements) &&
  1077. parsedElements.length > 0 &&
  1078. parsedElements[0].type // need to implement a better check here...
  1079. ) {
  1080. elements = clearSelection(elements);
  1081. let subCanvasX1 = Infinity;
  1082. let subCanvasX2 = 0;
  1083. let subCanvasY1 = Infinity;
  1084. let subCanvasY2 = 0;
  1085. const minX = Math.min(...parsedElements.map(element => element.x));
  1086. const minY = Math.min(...parsedElements.map(element => element.y));
  1087. const distance = (x: number, y: number) => {
  1088. return Math.abs(x > y ? x - y : y - x);
  1089. };
  1090. parsedElements.forEach(parsedElement => {
  1091. const [x1, y1, x2, y2] = getElementAbsoluteCoords(parsedElement);
  1092. subCanvasX1 = Math.min(subCanvasX1, x1);
  1093. subCanvasY1 = Math.min(subCanvasY1, y1);
  1094. subCanvasX2 = Math.max(subCanvasX2, x2);
  1095. subCanvasY2 = Math.max(subCanvasY2, y2);
  1096. });
  1097. const elementsCenterX = distance(subCanvasX1, subCanvasX2) / 2;
  1098. const elementsCenterY = distance(subCanvasY1, subCanvasY2) / 2;
  1099. const dx =
  1100. this.state.cursorX -
  1101. this.state.scrollX -
  1102. CANVAS_WINDOW_OFFSET_LEFT -
  1103. elementsCenterX;
  1104. const dy =
  1105. this.state.cursorY -
  1106. this.state.scrollY -
  1107. CANVAS_WINDOW_OFFSET_TOP -
  1108. elementsCenterY;
  1109. elements = [
  1110. ...elements,
  1111. ...parsedElements.map(parsedElement => {
  1112. const duplicate = duplicateElement(parsedElement);
  1113. duplicate.x += dx - minX;
  1114. duplicate.y += dy - minY;
  1115. return duplicate;
  1116. })
  1117. ];
  1118. this.forceUpdate();
  1119. }
  1120. };
  1121. private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {
  1122. const elementClickedInside = getElementContainingPosition(elements, x, y);
  1123. if (elementClickedInside) {
  1124. const elementCenterX =
  1125. elementClickedInside.x + elementClickedInside.width / 2;
  1126. const elementCenterY =
  1127. elementClickedInside.y + elementClickedInside.height / 2;
  1128. const distanceToCenter = Math.hypot(
  1129. x - elementCenterX,
  1130. y - elementCenterY
  1131. );
  1132. const isSnappedToCenter =
  1133. distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
  1134. if (isSnappedToCenter) {
  1135. const wysiwygX =
  1136. this.state.scrollX +
  1137. elementClickedInside.x +
  1138. CANVAS_WINDOW_OFFSET_LEFT +
  1139. elementClickedInside.width / 2;
  1140. const wysiwygY =
  1141. this.state.scrollY +
  1142. elementClickedInside.y +
  1143. CANVAS_WINDOW_OFFSET_TOP +
  1144. elementClickedInside.height / 2;
  1145. return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
  1146. }
  1147. }
  1148. }
  1149. private saveDebounced = debounce(() => {
  1150. saveToLocalStorage(elements, this.state);
  1151. }, 300);
  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. this.saveDebounced();
  1159. if (history.isRecording()) {
  1160. history.pushEntry(history.generateCurrentEntry(elements));
  1161. }
  1162. }
  1163. }
  1164. const rootElement = document.getElementById("root");
  1165. ReactDOM.render(<App />, rootElement);