index.tsx 69 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047
  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. newTextElement,
  8. duplicateElement,
  9. resizeTest,
  10. normalizeResizeHandle,
  11. isInvisiblySmallElement,
  12. isTextElement,
  13. textWysiwyg,
  14. getCommonBounds,
  15. getCursorForResizingElement,
  16. getPerfectElementSize,
  17. normalizeDimensions,
  18. } from "./element";
  19. import {
  20. clearSelection,
  21. deleteSelectedElements,
  22. getElementsWithinSelection,
  23. isOverScrollBars,
  24. saveToLocalStorage,
  25. getElementAtPosition,
  26. createScene,
  27. getElementContainingPosition,
  28. hasBackground,
  29. hasStroke,
  30. hasText,
  31. exportCanvas,
  32. loadScene,
  33. calculateScrollCenter,
  34. loadFromBlob,
  35. } from "./scene";
  36. import { renderScene } from "./renderer";
  37. import { AppState } from "./types";
  38. import { ExcalidrawElement } from "./element/types";
  39. import {
  40. isWritableElement,
  41. isInputLike,
  42. isToolIcon,
  43. debounce,
  44. capitalizeString,
  45. distance,
  46. distance2d,
  47. resetCursor,
  48. } from "./utils";
  49. import { KEYS, isArrowKey } from "./keys";
  50. import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes";
  51. import { createHistory } from "./history";
  52. import ContextMenu from "./components/ContextMenu";
  53. import "./styles.scss";
  54. import { getElementWithResizeHandler } from "./element/resizeTest";
  55. import {
  56. ActionManager,
  57. actionDeleteSelected,
  58. actionSendBackward,
  59. actionBringForward,
  60. actionSendToBack,
  61. actionBringToFront,
  62. actionSelectAll,
  63. actionChangeStrokeColor,
  64. actionChangeBackgroundColor,
  65. actionChangeOpacity,
  66. actionChangeStrokeWidth,
  67. actionChangeFillStyle,
  68. actionChangeSloppiness,
  69. actionChangeFontSize,
  70. actionChangeFontFamily,
  71. actionChangeViewBackgroundColor,
  72. actionClearCanvas,
  73. actionChangeProjectName,
  74. actionChangeExportBackground,
  75. actionLoadScene,
  76. actionSaveScene,
  77. actionCopyStyles,
  78. actionPasteStyles,
  79. actionFinalize,
  80. } from "./actions";
  81. import { Action, ActionResult } from "./actions/types";
  82. import { getDefaultAppState } from "./appState";
  83. import { Island } from "./components/Island";
  84. import Stack from "./components/Stack";
  85. import { FixedSideContainer } from "./components/FixedSideContainer";
  86. import { ToolButton } from "./components/ToolButton";
  87. import { LockIcon } from "./components/LockIcon";
  88. import { ExportDialog } from "./components/ExportDialog";
  89. import { LanguageList } from "./components/LanguageList";
  90. import { Point } from "roughjs/bin/geometry";
  91. import { t, languages, setLanguage, getLanguage } from "./i18n";
  92. import { HintViewer } from "./components/HintViewer";
  93. import { copyToAppClipboard, getClipboardContent } from "./clipboard";
  94. let { elements } = createScene();
  95. const { history } = createHistory();
  96. function setCursorForShape(shape: string) {
  97. if (shape === "selection") {
  98. resetCursor();
  99. } else {
  100. document.documentElement.style.cursor =
  101. shape === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
  102. }
  103. }
  104. const DRAGGING_THRESHOLD = 10; // 10px
  105. const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
  106. const ELEMENT_TRANSLATE_AMOUNT = 1;
  107. const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
  108. const CURSOR_TYPE = {
  109. TEXT: "text",
  110. CROSSHAIR: "crosshair",
  111. GRABBING: "grabbing",
  112. };
  113. const MOUSE_BUTTON = {
  114. MAIN: 0,
  115. WHEEL: 1,
  116. SECONDARY: 2,
  117. };
  118. let lastCanvasWidth = -1;
  119. let lastCanvasHeight = -1;
  120. let lastMouseUp: ((e: any) => void) | null = null;
  121. export function viewportCoordsToSceneCoords(
  122. { clientX, clientY }: { clientX: number; clientY: number },
  123. { scrollX, scrollY }: { scrollX: number; scrollY: number },
  124. ) {
  125. const x = clientX - scrollX;
  126. const y = clientY - scrollY;
  127. return { x, y };
  128. }
  129. let cursorX = 0;
  130. let cursorY = 0;
  131. let isHoldingSpace: boolean = false;
  132. let isPanning: boolean = false;
  133. let isHoldingMouseButton: boolean = false;
  134. interface LayerUIProps {
  135. actionManager: ActionManager;
  136. appState: AppState;
  137. canvas: HTMLCanvasElement | null;
  138. setAppState: any;
  139. elements: readonly ExcalidrawElement[];
  140. language: string;
  141. setElements: (elements: readonly ExcalidrawElement[]) => void;
  142. }
  143. const LayerUI = React.memo(
  144. ({
  145. actionManager,
  146. appState,
  147. setAppState,
  148. canvas,
  149. elements,
  150. language,
  151. setElements,
  152. }: LayerUIProps) => {
  153. function renderCanvasActions() {
  154. return (
  155. <Stack.Col gap={4}>
  156. <Stack.Row justifyContent={"space-between"}>
  157. {actionManager.renderAction("loadScene")}
  158. {actionManager.renderAction("saveScene")}
  159. <ExportDialog
  160. elements={elements}
  161. appState={appState}
  162. actionManager={actionManager}
  163. onExportToPng={(exportedElements, scale) => {
  164. if (canvas) {
  165. exportCanvas("png", exportedElements, canvas, {
  166. exportBackground: appState.exportBackground,
  167. name: appState.name,
  168. viewBackgroundColor: appState.viewBackgroundColor,
  169. scale,
  170. });
  171. }
  172. }}
  173. onExportToSvg={(exportedElements, scale) => {
  174. if (canvas) {
  175. exportCanvas("svg", exportedElements, canvas, {
  176. exportBackground: appState.exportBackground,
  177. name: appState.name,
  178. viewBackgroundColor: appState.viewBackgroundColor,
  179. scale,
  180. });
  181. }
  182. }}
  183. onExportToClipboard={(exportedElements, scale) => {
  184. if (canvas) {
  185. exportCanvas("clipboard", exportedElements, canvas, {
  186. exportBackground: appState.exportBackground,
  187. name: appState.name,
  188. viewBackgroundColor: appState.viewBackgroundColor,
  189. scale,
  190. });
  191. }
  192. }}
  193. onExportToBackend={exportedElements => {
  194. if (canvas) {
  195. exportCanvas(
  196. "backend",
  197. exportedElements.map(element => ({
  198. ...element,
  199. isSelected: false,
  200. })),
  201. canvas,
  202. appState,
  203. );
  204. }
  205. }}
  206. />
  207. {actionManager.renderAction("clearCanvas")}
  208. </Stack.Row>
  209. {actionManager.renderAction("changeViewBackgroundColor")}
  210. </Stack.Col>
  211. );
  212. }
  213. function renderSelectedShapeActions(
  214. elements: readonly ExcalidrawElement[],
  215. ) {
  216. const { elementType, editingElement } = appState;
  217. const targetElements = editingElement
  218. ? [editingElement]
  219. : elements.filter(el => el.isSelected);
  220. if (!targetElements.length && elementType === "selection") {
  221. return null;
  222. }
  223. return (
  224. <Island padding={4}>
  225. <div className="panelColumn">
  226. {actionManager.renderAction("changeStrokeColor")}
  227. {(hasBackground(elementType) ||
  228. targetElements.some(element => hasBackground(element.type))) && (
  229. <>
  230. {actionManager.renderAction("changeBackgroundColor")}
  231. {actionManager.renderAction("changeFillStyle")}
  232. </>
  233. )}
  234. {(hasStroke(elementType) ||
  235. targetElements.some(element => hasStroke(element.type))) && (
  236. <>
  237. {actionManager.renderAction("changeStrokeWidth")}
  238. {actionManager.renderAction("changeSloppiness")}
  239. </>
  240. )}
  241. {(hasText(elementType) ||
  242. targetElements.some(element => hasText(element.type))) && (
  243. <>
  244. {actionManager.renderAction("changeFontSize")}
  245. {actionManager.renderAction("changeFontFamily")}
  246. </>
  247. )}
  248. {actionManager.renderAction("changeOpacity")}
  249. <fieldset>
  250. <legend>{t("labels.layers")}</legend>
  251. <div className="buttonList">
  252. {actionManager.renderAction("sendToBack")}
  253. {actionManager.renderAction("sendBackward")}
  254. {actionManager.renderAction("bringToFront")}
  255. {actionManager.renderAction("bringForward")}
  256. </div>
  257. </fieldset>
  258. {actionManager.renderAction("deleteSelectedElements")}
  259. </div>
  260. </Island>
  261. );
  262. }
  263. function renderShapesSwitcher() {
  264. return (
  265. <>
  266. {SHAPES.map(({ value, icon }, index) => {
  267. const label = t(`toolBar.${value}`);
  268. return (
  269. <ToolButton
  270. key={value}
  271. type="radio"
  272. icon={icon}
  273. checked={appState.elementType === value}
  274. name="editor-current-shape"
  275. title={`${capitalizeString(label)} — ${
  276. capitalizeString(value)[0]
  277. }, ${index + 1}`}
  278. keyBindingLabel={`${index + 1}`}
  279. aria-label={capitalizeString(label)}
  280. aria-keyshortcuts={`${label[0]} ${index + 1}`}
  281. onChange={() => {
  282. setAppState({ elementType: value, multiElement: null });
  283. setElements(clearSelection(elements));
  284. document.documentElement.style.cursor =
  285. value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
  286. setAppState({});
  287. }}
  288. ></ToolButton>
  289. );
  290. })}
  291. </>
  292. );
  293. }
  294. return (
  295. <>
  296. <FixedSideContainer side="top">
  297. <div className="App-menu App-menu_top">
  298. <Stack.Col gap={4} align="end">
  299. <section
  300. className="App-right-menu"
  301. aria-labelledby="canvas-actions-title"
  302. >
  303. <h2 className="visually-hidden" id="canvas-actions-title">
  304. {t("headings.canvasActions")}
  305. </h2>
  306. <Island padding={4}>{renderCanvasActions()}</Island>
  307. </section>
  308. <section
  309. className="App-right-menu"
  310. aria-labelledby="selected-shape-title"
  311. >
  312. <h2 className="visually-hidden" id="selected-shape-title">
  313. {t("headings.selectedShapeActions")}
  314. </h2>
  315. {renderSelectedShapeActions(elements)}
  316. </section>
  317. </Stack.Col>
  318. <section aria-labelledby="shapes-title">
  319. <Stack.Col gap={4} align="start">
  320. <Stack.Row gap={1}>
  321. <Island padding={1}>
  322. <h2 className="visually-hidden" id="shapes-title">
  323. {t("headings.shapes")}
  324. </h2>
  325. <Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
  326. </Island>
  327. <LockIcon
  328. checked={appState.elementLocked}
  329. onChange={() => {
  330. setAppState({
  331. elementLocked: !appState.elementLocked,
  332. elementType: appState.elementLocked
  333. ? "selection"
  334. : appState.elementType,
  335. });
  336. }}
  337. title={t("toolBar.lock")}
  338. />
  339. </Stack.Row>
  340. </Stack.Col>
  341. </section>
  342. <div />
  343. </div>
  344. </FixedSideContainer>
  345. <footer role="contentinfo">
  346. <HintViewer
  347. elementType={appState.elementType}
  348. multiMode={appState.multiElement !== null}
  349. isResizing={appState.isResizing}
  350. elements={elements}
  351. />
  352. <LanguageList
  353. onChange={lng => {
  354. setLanguage(lng);
  355. setAppState({});
  356. }}
  357. languages={languages}
  358. currentLanguage={language}
  359. />
  360. {appState.scrolledOutside && (
  361. <button
  362. className="scroll-back-to-content"
  363. onClick={() => {
  364. setAppState({ ...calculateScrollCenter(elements) });
  365. }}
  366. >
  367. {t("buttons.scrollBackToContent")}
  368. </button>
  369. )}
  370. </footer>
  371. </>
  372. );
  373. },
  374. (prev, next) => {
  375. const getNecessaryObj = (appState: AppState): Partial<AppState> => {
  376. const {
  377. draggingElement,
  378. resizingElement,
  379. multiElement,
  380. editingElement,
  381. isResizing,
  382. cursorX,
  383. cursorY,
  384. ...ret
  385. } = appState;
  386. return ret;
  387. };
  388. const prevAppState = getNecessaryObj(prev.appState);
  389. const nextAppState = getNecessaryObj(next.appState);
  390. const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
  391. return (
  392. prev.language === next.language &&
  393. prev.elements === next.elements &&
  394. keys.every(k => prevAppState[k] === nextAppState[k])
  395. );
  396. },
  397. );
  398. export class App extends React.Component<any, AppState> {
  399. canvas: HTMLCanvasElement | null = null;
  400. rc: RoughCanvas | null = null;
  401. actionManager: ActionManager;
  402. canvasOnlyActions: Array<Action>;
  403. constructor(props: any) {
  404. super(props);
  405. this.actionManager = new ActionManager(
  406. this.syncActionResult,
  407. () => this.state,
  408. () => elements,
  409. );
  410. this.actionManager.registerAction(actionFinalize);
  411. this.actionManager.registerAction(actionDeleteSelected);
  412. this.actionManager.registerAction(actionSendToBack);
  413. this.actionManager.registerAction(actionBringToFront);
  414. this.actionManager.registerAction(actionSendBackward);
  415. this.actionManager.registerAction(actionBringForward);
  416. this.actionManager.registerAction(actionSelectAll);
  417. this.actionManager.registerAction(actionChangeStrokeColor);
  418. this.actionManager.registerAction(actionChangeBackgroundColor);
  419. this.actionManager.registerAction(actionChangeFillStyle);
  420. this.actionManager.registerAction(actionChangeStrokeWidth);
  421. this.actionManager.registerAction(actionChangeOpacity);
  422. this.actionManager.registerAction(actionChangeSloppiness);
  423. this.actionManager.registerAction(actionChangeFontSize);
  424. this.actionManager.registerAction(actionChangeFontFamily);
  425. this.actionManager.registerAction(actionChangeViewBackgroundColor);
  426. this.actionManager.registerAction(actionClearCanvas);
  427. this.actionManager.registerAction(actionChangeProjectName);
  428. this.actionManager.registerAction(actionChangeExportBackground);
  429. this.actionManager.registerAction(actionSaveScene);
  430. this.actionManager.registerAction(actionLoadScene);
  431. this.actionManager.registerAction(actionCopyStyles);
  432. this.actionManager.registerAction(actionPasteStyles);
  433. this.canvasOnlyActions = [actionSelectAll];
  434. }
  435. private syncActionResult = (
  436. res: ActionResult,
  437. commitToHistory: boolean = true,
  438. ) => {
  439. if (this.unmounted) {
  440. return;
  441. }
  442. if (res.elements) {
  443. elements = res.elements;
  444. if (commitToHistory) {
  445. history.resumeRecording();
  446. }
  447. this.setState({});
  448. }
  449. if (res.appState) {
  450. if (commitToHistory) {
  451. history.resumeRecording();
  452. }
  453. this.setState({ ...res.appState });
  454. }
  455. };
  456. private onCut = (e: ClipboardEvent) => {
  457. if (isWritableElement(e.target)) {
  458. return;
  459. }
  460. copyToAppClipboard(elements);
  461. elements = deleteSelectedElements(elements);
  462. history.resumeRecording();
  463. this.setState({});
  464. e.preventDefault();
  465. };
  466. private onCopy = (e: ClipboardEvent) => {
  467. if (isWritableElement(e.target)) {
  468. return;
  469. }
  470. copyToAppClipboard(elements);
  471. e.preventDefault();
  472. };
  473. private onUnload = () => {
  474. isHoldingSpace = false;
  475. this.saveDebounced();
  476. this.saveDebounced.flush();
  477. };
  478. private disableEvent: EventHandlerNonNull = e => {
  479. e.preventDefault();
  480. };
  481. private unmounted = false;
  482. public async componentDidMount() {
  483. document.addEventListener("copy", this.onCopy);
  484. document.addEventListener("paste", this.pasteFromClipboard);
  485. document.addEventListener("cut", this.onCut);
  486. document.addEventListener("keydown", this.onKeyDown, false);
  487. document.addEventListener("keyup", this.onKeyUp, { passive: true });
  488. document.addEventListener("mousemove", this.updateCurrentCursorPosition);
  489. window.addEventListener("resize", this.onResize, false);
  490. window.addEventListener("unload", this.onUnload, false);
  491. window.addEventListener("blur", this.onUnload, false);
  492. window.addEventListener("dragover", this.disableEvent, false);
  493. window.addEventListener("drop", this.disableEvent, false);
  494. const searchParams = new URLSearchParams(window.location.search);
  495. const id = searchParams.get("id");
  496. if (id) {
  497. // Backwards compatibility with legacy url format
  498. const scene = await loadScene(id);
  499. this.syncActionResult(scene);
  500. } else {
  501. const match = window.location.hash.match(
  502. /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
  503. );
  504. if (match) {
  505. const scene = await loadScene(match[1], match[2]);
  506. this.syncActionResult(scene);
  507. } else {
  508. const scene = await loadScene(null);
  509. this.syncActionResult(scene);
  510. }
  511. }
  512. }
  513. public componentWillUnmount() {
  514. this.unmounted = true;
  515. document.removeEventListener("copy", this.onCopy);
  516. document.removeEventListener("paste", this.pasteFromClipboard);
  517. document.removeEventListener("cut", this.onCut);
  518. document.removeEventListener("keydown", this.onKeyDown, false);
  519. document.removeEventListener(
  520. "mousemove",
  521. this.updateCurrentCursorPosition,
  522. false,
  523. );
  524. document.removeEventListener("keyup", this.onKeyUp);
  525. window.removeEventListener("resize", this.onResize, false);
  526. window.removeEventListener("unload", this.onUnload, false);
  527. window.removeEventListener("blur", this.onUnload, false);
  528. window.removeEventListener("dragover", this.disableEvent, false);
  529. window.removeEventListener("drop", this.disableEvent, false);
  530. }
  531. public state: AppState = getDefaultAppState();
  532. private onResize = () => {
  533. this.setState({});
  534. };
  535. private updateCurrentCursorPosition = (e: MouseEvent) => {
  536. cursorX = e.x;
  537. cursorY = e.y;
  538. };
  539. private onKeyDown = (event: KeyboardEvent) => {
  540. if (
  541. (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) ||
  542. // case: using arrows to move between buttons
  543. (isArrowKey(event.key) && isInputLike(event.target))
  544. ) {
  545. return;
  546. }
  547. if (this.actionManager.handleKeyDown(event)) {
  548. return;
  549. }
  550. const shape = findShapeByKey(event.key);
  551. if (isArrowKey(event.key)) {
  552. const step = event.shiftKey
  553. ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
  554. : ELEMENT_TRANSLATE_AMOUNT;
  555. elements = elements.map(el => {
  556. if (el.isSelected) {
  557. const element = { ...el };
  558. if (event.key === KEYS.ARROW_LEFT) {
  559. element.x -= step;
  560. } else if (event.key === KEYS.ARROW_RIGHT) {
  561. element.x += step;
  562. } else if (event.key === KEYS.ARROW_UP) {
  563. element.y -= step;
  564. } else if (event.key === KEYS.ARROW_DOWN) {
  565. element.y += step;
  566. }
  567. return element;
  568. }
  569. return el;
  570. });
  571. this.setState({});
  572. event.preventDefault();
  573. } else if (
  574. shapesShortcutKeys.includes(event.key.toLowerCase()) &&
  575. !event.ctrlKey &&
  576. !event.altKey &&
  577. !event.metaKey &&
  578. this.state.draggingElement === null
  579. ) {
  580. this.selectShapeTool(shape);
  581. // Undo action
  582. } else if (event[KEYS.META] && /z/i.test(event.key)) {
  583. event.preventDefault();
  584. if (
  585. this.state.multiElement ||
  586. this.state.resizingElement ||
  587. this.state.editingElement ||
  588. this.state.draggingElement
  589. ) {
  590. return;
  591. }
  592. if (event.shiftKey) {
  593. // Redo action
  594. const data = history.redoOnce();
  595. if (data !== null) {
  596. elements = data.elements;
  597. this.setState({ ...data.appState });
  598. }
  599. } else {
  600. // undo action
  601. const data = history.undoOnce();
  602. if (data !== null) {
  603. elements = data.elements;
  604. this.setState({ ...data.appState });
  605. }
  606. }
  607. } else if (event.key === KEYS.SPACE && !isHoldingMouseButton) {
  608. isHoldingSpace = true;
  609. document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
  610. }
  611. };
  612. private onKeyUp = (event: KeyboardEvent) => {
  613. if (event.key === KEYS.SPACE) {
  614. if (this.state.elementType === "selection") {
  615. resetCursor();
  616. } else {
  617. elements = clearSelection(elements);
  618. document.documentElement.style.cursor =
  619. this.state.elementType === "text"
  620. ? CURSOR_TYPE.TEXT
  621. : CURSOR_TYPE.CROSSHAIR;
  622. this.setState({});
  623. }
  624. isHoldingSpace = false;
  625. }
  626. };
  627. private removeWheelEventListener: (() => void) | undefined;
  628. private copyToAppClipboard = () => {
  629. copyToAppClipboard(elements);
  630. };
  631. private pasteFromClipboard = async (e: ClipboardEvent | null) => {
  632. // #686
  633. const target = document.activeElement;
  634. const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
  635. if (
  636. // if no ClipboardEvent supplied, assume we're pasting via contextMenu
  637. // thus these checks don't make sense
  638. !e ||
  639. (elementUnderCursor instanceof HTMLCanvasElement &&
  640. !isWritableElement(target))
  641. ) {
  642. const data = await getClipboardContent(e);
  643. if (data.elements) {
  644. this.addElementsFromPaste(data.elements);
  645. } else if (data.text) {
  646. const { x, y } = viewportCoordsToSceneCoords(
  647. { clientX: cursorX, clientY: cursorY },
  648. this.state,
  649. );
  650. const element = newTextElement(
  651. newElement(
  652. "text",
  653. x,
  654. y,
  655. this.state.currentItemStrokeColor,
  656. this.state.currentItemBackgroundColor,
  657. this.state.currentItemFillStyle,
  658. this.state.currentItemStrokeWidth,
  659. this.state.currentItemRoughness,
  660. this.state.currentItemOpacity,
  661. ),
  662. data.text,
  663. this.state.currentItemFont,
  664. );
  665. element.isSelected = true;
  666. elements = [...clearSelection(elements), element];
  667. history.resumeRecording();
  668. }
  669. this.selectShapeTool("selection");
  670. e?.preventDefault();
  671. }
  672. };
  673. private selectShapeTool(elementType: AppState["elementType"]) {
  674. if (!isHoldingSpace) {
  675. setCursorForShape(elementType);
  676. }
  677. if (isToolIcon(document.activeElement)) {
  678. document.activeElement.blur();
  679. }
  680. if (elementType !== "selection") {
  681. elements = clearSelection(elements);
  682. }
  683. this.setState({ elementType });
  684. }
  685. setAppState = (obj: any) => {
  686. this.setState(obj);
  687. };
  688. setElements = (elements_: readonly ExcalidrawElement[]) => {
  689. elements = elements_;
  690. this.setState({});
  691. };
  692. public render() {
  693. const canvasWidth = window.innerWidth;
  694. const canvasHeight = window.innerHeight;
  695. return (
  696. <div className="container">
  697. <LayerUI
  698. canvas={this.canvas}
  699. appState={this.state}
  700. setAppState={this.setAppState}
  701. actionManager={this.actionManager}
  702. elements={elements}
  703. setElements={this.setElements}
  704. language={getLanguage()}
  705. />
  706. <main>
  707. <canvas
  708. id="canvas"
  709. style={{
  710. width: canvasWidth,
  711. height: canvasHeight,
  712. }}
  713. width={canvasWidth * window.devicePixelRatio}
  714. height={canvasHeight * window.devicePixelRatio}
  715. ref={canvas => {
  716. if (this.canvas === null) {
  717. this.canvas = canvas;
  718. this.rc = rough.canvas(this.canvas!);
  719. }
  720. if (this.removeWheelEventListener) {
  721. this.removeWheelEventListener();
  722. this.removeWheelEventListener = undefined;
  723. }
  724. if (canvas) {
  725. canvas.addEventListener("wheel", this.handleWheel, {
  726. passive: false,
  727. });
  728. this.removeWheelEventListener = () =>
  729. canvas.removeEventListener("wheel", this.handleWheel);
  730. // Whenever React sets the width/height of the canvas element,
  731. // the context loses the scale transform. We need to re-apply it
  732. if (
  733. canvasWidth !== lastCanvasWidth ||
  734. canvasHeight !== lastCanvasHeight
  735. ) {
  736. lastCanvasWidth = canvasWidth;
  737. lastCanvasHeight = canvasHeight;
  738. canvas
  739. .getContext("2d")!
  740. .scale(window.devicePixelRatio, window.devicePixelRatio);
  741. }
  742. }
  743. }}
  744. onContextMenu={e => {
  745. e.preventDefault();
  746. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  747. const element = getElementAtPosition(elements, x, y);
  748. if (!element) {
  749. ContextMenu.push({
  750. options: [
  751. navigator.clipboard && {
  752. label: t("labels.paste"),
  753. action: () => this.pasteFromClipboard(null),
  754. },
  755. ...this.actionManager.getContextMenuItems(action =>
  756. this.canvasOnlyActions.includes(action),
  757. ),
  758. ],
  759. top: e.clientY,
  760. left: e.clientX,
  761. });
  762. return;
  763. }
  764. if (!element.isSelected) {
  765. elements = clearSelection(elements);
  766. element.isSelected = true;
  767. this.setState({});
  768. }
  769. ContextMenu.push({
  770. options: [
  771. navigator.clipboard && {
  772. label: t("labels.copy"),
  773. action: this.copyToAppClipboard,
  774. },
  775. navigator.clipboard && {
  776. label: t("labels.paste"),
  777. action: () => this.pasteFromClipboard(null),
  778. },
  779. ...this.actionManager.getContextMenuItems(
  780. action => !this.canvasOnlyActions.includes(action),
  781. ),
  782. ],
  783. top: e.clientY,
  784. left: e.clientX,
  785. });
  786. }}
  787. onMouseDown={e => {
  788. if (lastMouseUp !== null) {
  789. // Unfortunately, sometimes we don't get a mouseup after a mousedown,
  790. // this can happen when a contextual menu or alert is triggered. In order to avoid
  791. // being in a weird state, we clean up on the next mousedown
  792. lastMouseUp(e);
  793. }
  794. if (isPanning) {
  795. return;
  796. }
  797. // pan canvas on wheel button drag or space+drag
  798. if (
  799. !isHoldingMouseButton &&
  800. (e.button === MOUSE_BUTTON.WHEEL ||
  801. (e.button === MOUSE_BUTTON.MAIN && isHoldingSpace))
  802. ) {
  803. isHoldingMouseButton = true;
  804. isPanning = true;
  805. document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
  806. let { clientX: lastX, clientY: lastY } = e;
  807. const onMouseMove = (e: MouseEvent) => {
  808. const deltaX = lastX - e.clientX;
  809. const deltaY = lastY - e.clientY;
  810. lastX = e.clientX;
  811. lastY = e.clientY;
  812. this.setState({
  813. scrollX: this.state.scrollX - deltaX,
  814. scrollY: this.state.scrollY - deltaY,
  815. });
  816. };
  817. const teardown = (lastMouseUp = () => {
  818. lastMouseUp = null;
  819. isPanning = false;
  820. isHoldingMouseButton = false;
  821. if (!isHoldingSpace) {
  822. setCursorForShape(this.state.elementType);
  823. }
  824. window.removeEventListener("mousemove", onMouseMove);
  825. window.removeEventListener("mouseup", teardown);
  826. window.removeEventListener("blur", teardown);
  827. });
  828. window.addEventListener("blur", teardown);
  829. window.addEventListener("mousemove", onMouseMove, {
  830. passive: true,
  831. });
  832. window.addEventListener("mouseup", teardown);
  833. return;
  834. }
  835. // only handle left mouse button
  836. if (e.button !== MOUSE_BUTTON.MAIN) {
  837. return;
  838. }
  839. // fixes mousemove causing selection of UI texts #32
  840. e.preventDefault();
  841. // Preventing the event above disables default behavior
  842. // of defocusing potentially focused element, which is what we
  843. // want when clicking inside the canvas.
  844. if (document.activeElement instanceof HTMLElement) {
  845. document.activeElement.blur();
  846. }
  847. // Handle scrollbars dragging
  848. const {
  849. isOverHorizontalScrollBar,
  850. isOverVerticalScrollBar,
  851. } = isOverScrollBars(
  852. elements,
  853. e.clientX / window.devicePixelRatio,
  854. e.clientY / window.devicePixelRatio,
  855. canvasWidth / window.devicePixelRatio,
  856. canvasHeight / window.devicePixelRatio,
  857. this.state.scrollX,
  858. this.state.scrollY,
  859. );
  860. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  861. const originX = x;
  862. const originY = y;
  863. let element = newElement(
  864. this.state.elementType,
  865. x,
  866. y,
  867. this.state.currentItemStrokeColor,
  868. this.state.currentItemBackgroundColor,
  869. this.state.currentItemFillStyle,
  870. this.state.currentItemStrokeWidth,
  871. this.state.currentItemRoughness,
  872. this.state.currentItemOpacity,
  873. );
  874. if (isTextElement(element)) {
  875. element = newTextElement(
  876. element,
  877. "",
  878. this.state.currentItemFont,
  879. );
  880. }
  881. type ResizeTestType = ReturnType<typeof resizeTest>;
  882. let resizeHandle: ResizeTestType = false;
  883. let isResizingElements = false;
  884. let draggingOccurred = false;
  885. let hitElement: ExcalidrawElement | null = null;
  886. let elementIsAddedToSelection = false;
  887. if (this.state.elementType === "selection") {
  888. const resizeElement = getElementWithResizeHandler(
  889. elements,
  890. { x, y },
  891. this.state,
  892. );
  893. this.setState({
  894. resizingElement: resizeElement ? resizeElement.element : null,
  895. });
  896. if (resizeElement) {
  897. resizeHandle = resizeElement.resizeHandle;
  898. document.documentElement.style.cursor = getCursorForResizingElement(
  899. resizeElement,
  900. );
  901. isResizingElements = true;
  902. } else {
  903. hitElement = getElementAtPosition(elements, x, y);
  904. // clear selection if shift is not clicked
  905. if (!hitElement?.isSelected && !e.shiftKey) {
  906. elements = clearSelection(elements);
  907. }
  908. // If we click on something
  909. if (hitElement) {
  910. // deselect if item is selected
  911. // if shift is not clicked, this will always return true
  912. // otherwise, it will trigger selection based on current
  913. // state of the box
  914. if (!hitElement.isSelected) {
  915. hitElement.isSelected = true;
  916. elements = elements.slice();
  917. elementIsAddedToSelection = true;
  918. }
  919. // We duplicate the selected element if alt is pressed on Mouse down
  920. if (e.altKey) {
  921. elements = [
  922. ...elements.map(element => ({
  923. ...element,
  924. isSelected: false,
  925. })),
  926. ...elements
  927. .filter(element => element.isSelected)
  928. .map(element => {
  929. const newElement = duplicateElement(element);
  930. newElement.isSelected = true;
  931. return newElement;
  932. }),
  933. ];
  934. }
  935. }
  936. }
  937. } else {
  938. elements = clearSelection(elements);
  939. }
  940. if (isTextElement(element)) {
  941. // if we're currently still editing text, clicking outside
  942. // should only finalize it, not create another (irrespective
  943. // of state.elementLocked)
  944. if (this.state.editingElement?.type === "text") {
  945. return;
  946. }
  947. let textX = e.clientX;
  948. let textY = e.clientY;
  949. if (!e.altKey) {
  950. const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
  951. x,
  952. y,
  953. );
  954. if (snappedToCenterPosition) {
  955. element.x = snappedToCenterPosition.elementCenterX;
  956. element.y = snappedToCenterPosition.elementCenterY;
  957. textX = snappedToCenterPosition.wysiwygX;
  958. textY = snappedToCenterPosition.wysiwygY;
  959. }
  960. }
  961. const resetSelection = () => {
  962. this.setState({
  963. draggingElement: null,
  964. editingElement: null,
  965. });
  966. };
  967. textWysiwyg({
  968. initText: "",
  969. x: textX,
  970. y: textY,
  971. strokeColor: this.state.currentItemStrokeColor,
  972. opacity: this.state.currentItemOpacity,
  973. font: this.state.currentItemFont,
  974. onSubmit: text => {
  975. if (text) {
  976. elements = [
  977. ...elements,
  978. {
  979. ...newTextElement(
  980. element,
  981. text,
  982. this.state.currentItemFont,
  983. ),
  984. isSelected: true,
  985. },
  986. ];
  987. }
  988. if (this.state.elementLocked) {
  989. setCursorForShape(this.state.elementType);
  990. }
  991. history.resumeRecording();
  992. resetSelection();
  993. },
  994. onCancel: () => {
  995. resetSelection();
  996. },
  997. });
  998. resetCursor();
  999. if (!this.state.elementLocked) {
  1000. this.setState({
  1001. editingElement: element,
  1002. elementType: "selection",
  1003. });
  1004. } else {
  1005. this.setState({
  1006. editingElement: element,
  1007. });
  1008. }
  1009. return;
  1010. } else if (
  1011. this.state.elementType === "arrow" ||
  1012. this.state.elementType === "line"
  1013. ) {
  1014. if (this.state.multiElement) {
  1015. const { multiElement } = this.state;
  1016. const { x: rx, y: ry } = multiElement;
  1017. multiElement.isSelected = true;
  1018. multiElement.points.push([x - rx, y - ry]);
  1019. multiElement.shape = null;
  1020. } else {
  1021. element.isSelected = false;
  1022. element.points.push([0, 0]);
  1023. element.shape = null;
  1024. elements = [...elements, element];
  1025. this.setState({
  1026. draggingElement: element,
  1027. });
  1028. }
  1029. } else if (element.type === "selection") {
  1030. this.setState({
  1031. selectionElement: element,
  1032. draggingElement: element,
  1033. });
  1034. } else {
  1035. elements = [...elements, element];
  1036. this.setState({ multiElement: null, draggingElement: element });
  1037. }
  1038. let lastX = x;
  1039. let lastY = y;
  1040. if (isOverHorizontalScrollBar || isOverVerticalScrollBar) {
  1041. lastX = e.clientX;
  1042. lastY = e.clientY;
  1043. }
  1044. let resizeArrowFn:
  1045. | ((
  1046. element: ExcalidrawElement,
  1047. p1: Point,
  1048. deltaX: number,
  1049. deltaY: number,
  1050. mouseX: number,
  1051. mouseY: number,
  1052. perfect: boolean,
  1053. ) => void)
  1054. | null = null;
  1055. const arrowResizeOrigin = (
  1056. element: ExcalidrawElement,
  1057. p1: Point,
  1058. deltaX: number,
  1059. deltaY: number,
  1060. mouseX: number,
  1061. mouseY: number,
  1062. perfect: boolean,
  1063. ) => {
  1064. if (perfect) {
  1065. const absPx = p1[0] + element.x;
  1066. const absPy = p1[1] + element.y;
  1067. const { width, height } = getPerfectElementSize(
  1068. element.type,
  1069. mouseX - element.x - p1[0],
  1070. mouseY - element.y - p1[1],
  1071. );
  1072. const dx = element.x + width + p1[0];
  1073. const dy = element.y + height + p1[1];
  1074. element.x = dx;
  1075. element.y = dy;
  1076. p1[0] = absPx - element.x;
  1077. p1[1] = absPy - element.y;
  1078. } else {
  1079. element.x += deltaX;
  1080. element.y += deltaY;
  1081. p1[0] -= deltaX;
  1082. p1[1] -= deltaY;
  1083. }
  1084. };
  1085. const arrowResizeEnd = (
  1086. element: ExcalidrawElement,
  1087. p1: Point,
  1088. deltaX: number,
  1089. deltaY: number,
  1090. mouseX: number,
  1091. mouseY: number,
  1092. perfect: boolean,
  1093. ) => {
  1094. if (perfect) {
  1095. const { width, height } = getPerfectElementSize(
  1096. element.type,
  1097. mouseX - element.x,
  1098. mouseY - element.y,
  1099. );
  1100. p1[0] = width;
  1101. p1[1] = height;
  1102. } else {
  1103. p1[0] += deltaX;
  1104. p1[1] += deltaY;
  1105. }
  1106. };
  1107. const onMouseMove = (e: MouseEvent) => {
  1108. const target = e.target;
  1109. if (!(target instanceof HTMLElement)) {
  1110. return;
  1111. }
  1112. if (isOverHorizontalScrollBar) {
  1113. const x = e.clientX;
  1114. const dx = x - lastX;
  1115. this.setState({ scrollX: this.state.scrollX - dx });
  1116. lastX = x;
  1117. return;
  1118. }
  1119. if (isOverVerticalScrollBar) {
  1120. const y = e.clientY;
  1121. const dy = y - lastY;
  1122. this.setState({ scrollY: this.state.scrollY - dy });
  1123. lastY = y;
  1124. return;
  1125. }
  1126. // for arrows, don't start dragging until a given threshold
  1127. // to ensure we don't create a 2-point arrow by mistake when
  1128. // user clicks mouse in a way that it moves a tiny bit (thus
  1129. // triggering mousemove)
  1130. if (
  1131. !draggingOccurred &&
  1132. (this.state.elementType === "arrow" ||
  1133. this.state.elementType === "line")
  1134. ) {
  1135. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1136. if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
  1137. return;
  1138. }
  1139. }
  1140. if (isResizingElements && this.state.resizingElement) {
  1141. this.setState({ isResizing: true });
  1142. const el = this.state.resizingElement;
  1143. const selectedElements = elements.filter(el => el.isSelected);
  1144. if (selectedElements.length === 1) {
  1145. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1146. const deltaX = x - lastX;
  1147. const deltaY = y - lastY;
  1148. const element = selectedElements[0];
  1149. const isLinear =
  1150. element.type === "line" || element.type === "arrow";
  1151. switch (resizeHandle) {
  1152. case "nw":
  1153. if (isLinear && element.points.length === 2) {
  1154. const [, p1] = element.points;
  1155. if (!resizeArrowFn) {
  1156. if (p1[0] < 0 || p1[1] < 0) {
  1157. resizeArrowFn = arrowResizeEnd;
  1158. } else {
  1159. resizeArrowFn = arrowResizeOrigin;
  1160. }
  1161. }
  1162. resizeArrowFn(
  1163. element,
  1164. p1,
  1165. deltaX,
  1166. deltaY,
  1167. x,
  1168. y,
  1169. e.shiftKey,
  1170. );
  1171. } else {
  1172. element.width -= deltaX;
  1173. element.x += deltaX;
  1174. if (e.shiftKey) {
  1175. element.y += element.height - element.width;
  1176. element.height = element.width;
  1177. } else {
  1178. element.height -= deltaY;
  1179. element.y += deltaY;
  1180. }
  1181. }
  1182. break;
  1183. case "ne":
  1184. if (isLinear && element.points.length === 2) {
  1185. const [, p1] = element.points;
  1186. if (!resizeArrowFn) {
  1187. if (p1[0] >= 0) {
  1188. resizeArrowFn = arrowResizeEnd;
  1189. } else {
  1190. resizeArrowFn = arrowResizeOrigin;
  1191. }
  1192. }
  1193. resizeArrowFn(
  1194. element,
  1195. p1,
  1196. deltaX,
  1197. deltaY,
  1198. x,
  1199. y,
  1200. e.shiftKey,
  1201. );
  1202. } else {
  1203. element.width += deltaX;
  1204. if (e.shiftKey) {
  1205. element.y += element.height - element.width;
  1206. element.height = element.width;
  1207. } else {
  1208. element.height -= deltaY;
  1209. element.y += deltaY;
  1210. }
  1211. }
  1212. break;
  1213. case "sw":
  1214. if (isLinear && element.points.length === 2) {
  1215. const [, p1] = element.points;
  1216. if (!resizeArrowFn) {
  1217. if (p1[0] <= 0) {
  1218. resizeArrowFn = arrowResizeEnd;
  1219. } else {
  1220. resizeArrowFn = arrowResizeOrigin;
  1221. }
  1222. }
  1223. resizeArrowFn(
  1224. element,
  1225. p1,
  1226. deltaX,
  1227. deltaY,
  1228. x,
  1229. y,
  1230. e.shiftKey,
  1231. );
  1232. } else {
  1233. element.width -= deltaX;
  1234. element.x += deltaX;
  1235. if (e.shiftKey) {
  1236. element.height = element.width;
  1237. } else {
  1238. element.height += deltaY;
  1239. }
  1240. }
  1241. break;
  1242. case "se":
  1243. if (isLinear && element.points.length === 2) {
  1244. const [, p1] = element.points;
  1245. if (!resizeArrowFn) {
  1246. if (p1[0] > 0 || p1[1] > 0) {
  1247. resizeArrowFn = arrowResizeEnd;
  1248. } else {
  1249. resizeArrowFn = arrowResizeOrigin;
  1250. }
  1251. }
  1252. resizeArrowFn(
  1253. element,
  1254. p1,
  1255. deltaX,
  1256. deltaY,
  1257. x,
  1258. y,
  1259. e.shiftKey,
  1260. );
  1261. } else {
  1262. if (e.shiftKey) {
  1263. element.width += deltaX;
  1264. element.height = element.width;
  1265. } else {
  1266. element.width += deltaX;
  1267. element.height += deltaY;
  1268. }
  1269. }
  1270. break;
  1271. case "n": {
  1272. element.height -= deltaY;
  1273. element.y += deltaY;
  1274. if (element.points.length > 0) {
  1275. const len = element.points.length;
  1276. const points = [...element.points].sort(
  1277. (a, b) => a[1] - b[1],
  1278. );
  1279. for (let i = 1; i < points.length; ++i) {
  1280. const pnt = points[i];
  1281. pnt[1] -= deltaY / (len - i);
  1282. }
  1283. }
  1284. break;
  1285. }
  1286. case "w": {
  1287. element.width -= deltaX;
  1288. element.x += deltaX;
  1289. if (element.points.length > 0) {
  1290. const len = element.points.length;
  1291. const points = [...element.points].sort(
  1292. (a, b) => a[0] - b[0],
  1293. );
  1294. for (let i = 0; i < points.length; ++i) {
  1295. const pnt = points[i];
  1296. pnt[0] -= deltaX / (len - i);
  1297. }
  1298. }
  1299. break;
  1300. }
  1301. case "s": {
  1302. element.height += deltaY;
  1303. if (element.points.length > 0) {
  1304. const len = element.points.length;
  1305. const points = [...element.points].sort(
  1306. (a, b) => a[1] - b[1],
  1307. );
  1308. for (let i = 1; i < points.length; ++i) {
  1309. const pnt = points[i];
  1310. pnt[1] += deltaY / (len - i);
  1311. }
  1312. }
  1313. break;
  1314. }
  1315. case "e": {
  1316. element.width += deltaX;
  1317. if (element.points.length > 0) {
  1318. const len = element.points.length;
  1319. const points = [...element.points].sort(
  1320. (a, b) => a[0] - b[0],
  1321. );
  1322. for (let i = 1; i < points.length; ++i) {
  1323. const pnt = points[i];
  1324. pnt[0] += deltaX / (len - i);
  1325. }
  1326. }
  1327. break;
  1328. }
  1329. }
  1330. if (resizeHandle) {
  1331. resizeHandle = normalizeResizeHandle(
  1332. element,
  1333. resizeHandle,
  1334. );
  1335. }
  1336. normalizeDimensions(element);
  1337. document.documentElement.style.cursor = getCursorForResizingElement(
  1338. { element, resizeHandle },
  1339. );
  1340. el.x = element.x;
  1341. el.y = element.y;
  1342. el.shape = null;
  1343. lastX = x;
  1344. lastY = y;
  1345. this.setState({});
  1346. return;
  1347. }
  1348. }
  1349. if (hitElement?.isSelected) {
  1350. // Marking that click was used for dragging to check
  1351. // if elements should be deselected on mouseup
  1352. draggingOccurred = true;
  1353. const selectedElements = elements.filter(el => el.isSelected);
  1354. if (selectedElements.length) {
  1355. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1356. selectedElements.forEach(element => {
  1357. element.x += x - lastX;
  1358. element.y += y - lastY;
  1359. });
  1360. lastX = x;
  1361. lastY = y;
  1362. this.setState({});
  1363. return;
  1364. }
  1365. }
  1366. // It is very important to read this.state within each move event,
  1367. // otherwise we would read a stale one!
  1368. const draggingElement = this.state.draggingElement;
  1369. if (!draggingElement) {
  1370. return;
  1371. }
  1372. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1373. let width = distance(originX, x);
  1374. let height = distance(originY, y);
  1375. const isLinear =
  1376. this.state.elementType === "line" ||
  1377. this.state.elementType === "arrow";
  1378. if (isLinear) {
  1379. draggingOccurred = true;
  1380. const points = draggingElement.points;
  1381. let dx = x - draggingElement.x;
  1382. let dy = y - draggingElement.y;
  1383. if (e.shiftKey && points.length === 2) {
  1384. ({ width: dx, height: dy } = getPerfectElementSize(
  1385. this.state.elementType,
  1386. dx,
  1387. dy,
  1388. ));
  1389. }
  1390. if (points.length === 1) {
  1391. points.push([dx, dy]);
  1392. } else if (points.length > 1) {
  1393. const pnt = points[points.length - 1];
  1394. pnt[0] = dx;
  1395. pnt[1] = dy;
  1396. }
  1397. } else {
  1398. if (e.shiftKey) {
  1399. ({ width, height } = getPerfectElementSize(
  1400. this.state.elementType,
  1401. width,
  1402. y < originY ? -height : height,
  1403. ));
  1404. if (height < 0) {
  1405. height = -height;
  1406. }
  1407. }
  1408. draggingElement.x = x < originX ? originX - width : originX;
  1409. draggingElement.y = y < originY ? originY - height : originY;
  1410. draggingElement.width = width;
  1411. draggingElement.height = height;
  1412. }
  1413. draggingElement.shape = null;
  1414. if (this.state.elementType === "selection") {
  1415. if (!e.shiftKey && elements.some(el => el.isSelected)) {
  1416. elements = clearSelection(elements);
  1417. }
  1418. const elementsWithinSelection = getElementsWithinSelection(
  1419. elements,
  1420. draggingElement,
  1421. );
  1422. elementsWithinSelection.forEach(element => {
  1423. element.isSelected = true;
  1424. });
  1425. }
  1426. this.setState({});
  1427. };
  1428. const onMouseUp = (e: MouseEvent) => {
  1429. const {
  1430. draggingElement,
  1431. resizingElement,
  1432. multiElement,
  1433. elementType,
  1434. elementLocked,
  1435. } = this.state;
  1436. this.setState({
  1437. isResizing: false,
  1438. resizingElement: null,
  1439. selectionElement: null,
  1440. });
  1441. resizeArrowFn = null;
  1442. lastMouseUp = null;
  1443. isHoldingMouseButton = false;
  1444. window.removeEventListener("mousemove", onMouseMove);
  1445. window.removeEventListener("mouseup", onMouseUp);
  1446. if (elementType === "arrow" || elementType === "line") {
  1447. if (draggingElement!.points.length > 1) {
  1448. history.resumeRecording();
  1449. this.setState({});
  1450. }
  1451. if (!draggingOccurred && draggingElement && !multiElement) {
  1452. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1453. draggingElement.points.push([
  1454. x - draggingElement.x,
  1455. y - draggingElement.y,
  1456. ]);
  1457. draggingElement.shape = null;
  1458. this.setState({ multiElement: this.state.draggingElement });
  1459. } else if (draggingOccurred && !multiElement) {
  1460. this.state.draggingElement!.isSelected = true;
  1461. if (!elementLocked) {
  1462. resetCursor();
  1463. this.setState({
  1464. draggingElement: null,
  1465. elementType: "selection",
  1466. });
  1467. } else {
  1468. this.setState({
  1469. draggingElement: null,
  1470. });
  1471. }
  1472. }
  1473. return;
  1474. }
  1475. if (
  1476. elementType !== "selection" &&
  1477. draggingElement &&
  1478. isInvisiblySmallElement(draggingElement)
  1479. ) {
  1480. // remove invisible element which was added in onMouseDown
  1481. elements = elements.slice(0, -1);
  1482. this.setState({
  1483. draggingElement: null,
  1484. });
  1485. return;
  1486. }
  1487. if (normalizeDimensions(draggingElement)) {
  1488. this.setState({});
  1489. }
  1490. if (resizingElement) {
  1491. history.resumeRecording();
  1492. this.setState({});
  1493. }
  1494. if (
  1495. resizingElement &&
  1496. isInvisiblySmallElement(resizingElement)
  1497. ) {
  1498. elements = elements.filter(
  1499. el => el.id !== resizingElement.id,
  1500. );
  1501. }
  1502. // If click occurred on already selected element
  1503. // it is needed to remove selection from other elements
  1504. // or if SHIFT or META key pressed remove selection
  1505. // from hitted element
  1506. //
  1507. // If click occurred and elements were dragged or some element
  1508. // was added to selection (on mousedown phase) we need to keep
  1509. // selection unchanged
  1510. if (
  1511. hitElement &&
  1512. !draggingOccurred &&
  1513. !elementIsAddedToSelection
  1514. ) {
  1515. if (e.shiftKey) {
  1516. hitElement.isSelected = false;
  1517. } else {
  1518. elements = clearSelection(elements);
  1519. hitElement.isSelected = true;
  1520. }
  1521. }
  1522. if (draggingElement === null) {
  1523. // if no element is clicked, clear the selection and redraw
  1524. elements = clearSelection(elements);
  1525. this.setState({});
  1526. return;
  1527. }
  1528. if (!elementLocked) {
  1529. draggingElement.isSelected = true;
  1530. }
  1531. if (
  1532. elementType !== "selection" ||
  1533. elements.some(el => el.isSelected)
  1534. ) {
  1535. history.resumeRecording();
  1536. }
  1537. if (!elementLocked) {
  1538. resetCursor();
  1539. this.setState({
  1540. draggingElement: null,
  1541. elementType: "selection",
  1542. });
  1543. } else {
  1544. this.setState({
  1545. draggingElement: null,
  1546. });
  1547. }
  1548. };
  1549. lastMouseUp = onMouseUp;
  1550. window.addEventListener("mousemove", onMouseMove);
  1551. window.addEventListener("mouseup", onMouseUp);
  1552. }}
  1553. onDoubleClick={e => {
  1554. resetCursor();
  1555. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1556. const elementAtPosition = getElementAtPosition(elements, x, y);
  1557. const element =
  1558. elementAtPosition && isTextElement(elementAtPosition)
  1559. ? elementAtPosition
  1560. : newTextElement(
  1561. newElement(
  1562. "text",
  1563. x,
  1564. y,
  1565. this.state.currentItemStrokeColor,
  1566. this.state.currentItemBackgroundColor,
  1567. this.state.currentItemFillStyle,
  1568. this.state.currentItemStrokeWidth,
  1569. this.state.currentItemRoughness,
  1570. this.state.currentItemOpacity,
  1571. ),
  1572. "", // default text
  1573. this.state.currentItemFont, // default font
  1574. );
  1575. this.setState({ editingElement: element });
  1576. let textX = e.clientX;
  1577. let textY = e.clientY;
  1578. if (elementAtPosition && isTextElement(elementAtPosition)) {
  1579. elements = elements.filter(
  1580. element => element.id !== elementAtPosition.id,
  1581. );
  1582. this.setState({});
  1583. textX =
  1584. this.state.scrollX +
  1585. elementAtPosition.x +
  1586. elementAtPosition.width / 2;
  1587. textY =
  1588. this.state.scrollY +
  1589. elementAtPosition.y +
  1590. elementAtPosition.height / 2;
  1591. // x and y will change after calling newTextElement function
  1592. element.x = elementAtPosition.x + elementAtPosition.width / 2;
  1593. element.y = elementAtPosition.y + elementAtPosition.height / 2;
  1594. } else if (!e.altKey) {
  1595. const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
  1596. x,
  1597. y,
  1598. );
  1599. if (snappedToCenterPosition) {
  1600. element.x = snappedToCenterPosition.elementCenterX;
  1601. element.y = snappedToCenterPosition.elementCenterY;
  1602. textX = snappedToCenterPosition.wysiwygX;
  1603. textY = snappedToCenterPosition.wysiwygY;
  1604. }
  1605. }
  1606. const resetSelection = () => {
  1607. this.setState({
  1608. draggingElement: null,
  1609. editingElement: null,
  1610. });
  1611. };
  1612. textWysiwyg({
  1613. initText: element.text,
  1614. x: textX,
  1615. y: textY,
  1616. strokeColor: element.strokeColor,
  1617. font: element.font,
  1618. opacity: this.state.currentItemOpacity,
  1619. onSubmit: text => {
  1620. if (text) {
  1621. elements = [
  1622. ...elements,
  1623. {
  1624. // we need to recreate the element to update dimensions &
  1625. // position
  1626. ...newTextElement(element, text, element.font),
  1627. isSelected: true,
  1628. },
  1629. ];
  1630. }
  1631. history.resumeRecording();
  1632. resetSelection();
  1633. },
  1634. onCancel: () => {
  1635. resetSelection();
  1636. },
  1637. });
  1638. }}
  1639. onMouseMove={e => {
  1640. if (isHoldingSpace || isPanning) {
  1641. return;
  1642. }
  1643. const hasDeselectedButton = Boolean(e.buttons);
  1644. const { x, y } = viewportCoordsToSceneCoords(e, this.state);
  1645. if (this.state.multiElement) {
  1646. const { multiElement } = this.state;
  1647. const originX = multiElement.x;
  1648. const originY = multiElement.y;
  1649. const points = multiElement.points;
  1650. const pnt = points[points.length - 1];
  1651. pnt[0] = x - originX;
  1652. pnt[1] = y - originY;
  1653. multiElement.shape = null;
  1654. this.setState({});
  1655. return;
  1656. }
  1657. if (
  1658. hasDeselectedButton ||
  1659. this.state.elementType !== "selection"
  1660. ) {
  1661. return;
  1662. }
  1663. const selectedElements = elements.filter(e => e.isSelected)
  1664. .length;
  1665. if (selectedElements === 1) {
  1666. const resizeElement = getElementWithResizeHandler(
  1667. elements,
  1668. { x, y },
  1669. this.state,
  1670. );
  1671. if (resizeElement && resizeElement.resizeHandle) {
  1672. document.documentElement.style.cursor = getCursorForResizingElement(
  1673. resizeElement,
  1674. );
  1675. return;
  1676. }
  1677. }
  1678. const hitElement = getElementAtPosition(elements, x, y);
  1679. document.documentElement.style.cursor = hitElement ? "move" : "";
  1680. }}
  1681. onDrop={e => {
  1682. const file = e.dataTransfer.files[0];
  1683. if (file?.type === "application/json") {
  1684. loadFromBlob(file)
  1685. .then(({ elements, appState }) =>
  1686. this.syncActionResult({ elements, appState }),
  1687. )
  1688. .catch(err => console.error(err));
  1689. }
  1690. }}
  1691. >
  1692. {t("labels.drawingCanvas")}
  1693. </canvas>
  1694. </main>
  1695. </div>
  1696. );
  1697. }
  1698. private handleWheel = (e: WheelEvent) => {
  1699. e.preventDefault();
  1700. const { deltaX, deltaY } = e;
  1701. this.setState({
  1702. scrollX: this.state.scrollX - deltaX,
  1703. scrollY: this.state.scrollY - deltaY,
  1704. });
  1705. };
  1706. private addElementsFromPaste = (
  1707. clipboardElements: readonly ExcalidrawElement[],
  1708. ) => {
  1709. elements = clearSelection(elements);
  1710. const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
  1711. const elementsCenterX = distance(minX, maxX) / 2;
  1712. const elementsCenterY = distance(minY, maxY) / 2;
  1713. const dx = cursorX - this.state.scrollX - elementsCenterX;
  1714. const dy = cursorY - this.state.scrollY - elementsCenterY;
  1715. elements = [
  1716. ...elements,
  1717. ...clipboardElements.map(clipboardElements => {
  1718. const duplicate = duplicateElement(clipboardElements);
  1719. duplicate.x += dx - minX;
  1720. duplicate.y += dy - minY;
  1721. return duplicate;
  1722. }),
  1723. ];
  1724. history.resumeRecording();
  1725. this.setState({});
  1726. };
  1727. private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {
  1728. const elementClickedInside = getElementContainingPosition(elements, x, y);
  1729. if (elementClickedInside) {
  1730. const elementCenterX =
  1731. elementClickedInside.x + elementClickedInside.width / 2;
  1732. const elementCenterY =
  1733. elementClickedInside.y + elementClickedInside.height / 2;
  1734. const distanceToCenter = Math.hypot(
  1735. x - elementCenterX,
  1736. y - elementCenterY,
  1737. );
  1738. const isSnappedToCenter =
  1739. distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
  1740. if (isSnappedToCenter) {
  1741. const wysiwygX =
  1742. this.state.scrollX +
  1743. elementClickedInside.x +
  1744. elementClickedInside.width / 2;
  1745. const wysiwygY =
  1746. this.state.scrollY +
  1747. elementClickedInside.y +
  1748. elementClickedInside.height / 2;
  1749. return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
  1750. }
  1751. }
  1752. }
  1753. private saveDebounced = debounce(() => {
  1754. saveToLocalStorage(
  1755. elements.filter(x => x.type !== "selection"),
  1756. this.state,
  1757. );
  1758. }, 300);
  1759. componentDidUpdate() {
  1760. const atLeastOneVisibleElement = renderScene(
  1761. elements,
  1762. this.state.selectionElement,
  1763. this.rc!,
  1764. this.canvas!,
  1765. {
  1766. scrollX: this.state.scrollX,
  1767. scrollY: this.state.scrollY,
  1768. viewBackgroundColor: this.state.viewBackgroundColor,
  1769. },
  1770. );
  1771. const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
  1772. if (this.state.scrolledOutside !== scrolledOutside) {
  1773. this.setState({ scrolledOutside: scrolledOutside });
  1774. }
  1775. this.saveDebounced();
  1776. if (history.isRecording()) {
  1777. history.pushEntry(this.state, elements);
  1778. history.skipRecording();
  1779. }
  1780. }
  1781. }
  1782. const rootElement = document.getElementById("root");
  1783. class TopErrorBoundary extends React.Component {
  1784. state = { hasError: false, stack: "", localStorage: "" };
  1785. static getDerivedStateFromError(error: any) {
  1786. console.error(error);
  1787. return {
  1788. hasError: true,
  1789. localStorage: JSON.stringify({ ...localStorage }),
  1790. stack: error.stack,
  1791. };
  1792. }
  1793. private selectTextArea(event: React.MouseEvent<HTMLTextAreaElement>) {
  1794. (event.target as HTMLTextAreaElement).select();
  1795. }
  1796. private async createGithubIssue() {
  1797. let body = "";
  1798. try {
  1799. const templateStr = (await import("./bug-issue-template")).default;
  1800. if (typeof templateStr === "string") {
  1801. body = encodeURIComponent(templateStr);
  1802. }
  1803. } catch {}
  1804. window.open(
  1805. `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
  1806. );
  1807. }
  1808. render() {
  1809. if (this.state.hasError) {
  1810. return (
  1811. <div className="ErrorSplash">
  1812. <div className="ErrorSplash-messageContainer">
  1813. <div className="ErrorSplash-paragraph bigger">
  1814. Encountered an error. Please{" "}
  1815. <button onClick={() => window.location.reload()}>
  1816. reload the page
  1817. </button>
  1818. .
  1819. </div>
  1820. <div className="ErrorSplash-paragraph">
  1821. If reloading doesn't work. Try{" "}
  1822. <button
  1823. onClick={() => {
  1824. localStorage.clear();
  1825. window.location.reload();
  1826. }}
  1827. >
  1828. clearing the canvas
  1829. </button>
  1830. .<br />
  1831. <div className="smaller">
  1832. (This will unfortunately result in loss of work.)
  1833. </div>
  1834. </div>
  1835. <div>
  1836. <div className="ErrorSplash-paragraph">
  1837. Before doing so, we'd appreciate if you opened an issue on our{" "}
  1838. <button onClick={this.createGithubIssue}>bug tracker</button>.
  1839. Please include the following error stack trace & localStorage
  1840. content (provided it's not private):
  1841. </div>
  1842. <div className="ErrorSplash-paragraph">
  1843. <div className="ErrorSplash-details">
  1844. <label>Error stack trace:</label>
  1845. <textarea
  1846. rows={10}
  1847. onClick={this.selectTextArea}
  1848. defaultValue={this.state.stack}
  1849. />
  1850. <label>LocalStorage content:</label>
  1851. <textarea
  1852. rows={5}
  1853. onClick={this.selectTextArea}
  1854. defaultValue={this.state.localStorage}
  1855. />
  1856. </div>
  1857. </div>
  1858. </div>
  1859. </div>
  1860. </div>
  1861. );
  1862. }
  1863. return this.props.children;
  1864. }
  1865. }
  1866. ReactDOM.render(
  1867. <TopErrorBoundary>
  1868. <App />
  1869. </TopErrorBoundary>,
  1870. rootElement,
  1871. );