index.tsx 69 KB

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