LayerUI.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. import clsx from "clsx";
  2. import React, { useCallback } from "react";
  3. import { ActionManager } from "../actions/manager";
  4. import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
  5. import { exportCanvas } from "../data";
  6. import { isTextElement, showSelectedShapeActions } from "../element";
  7. import { NonDeletedExcalidrawElement } from "../element/types";
  8. import { Language, t } from "../i18n";
  9. import { calculateScrollCenter, getSelectedElements } from "../scene";
  10. import { ExportType } from "../scene/types";
  11. import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
  12. import { muteFSAbortError } from "../utils";
  13. import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
  14. import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
  15. import CollabButton from "./CollabButton";
  16. import { ErrorDialog } from "./ErrorDialog";
  17. import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
  18. import { FixedSideContainer } from "./FixedSideContainer";
  19. import { HintViewer } from "./HintViewer";
  20. import { Island } from "./Island";
  21. import { LoadingMessage } from "./LoadingMessage";
  22. import { LockButton } from "./LockButton";
  23. import { MobileMenu } from "./MobileMenu";
  24. import { PasteChartDialog } from "./PasteChartDialog";
  25. import { Section } from "./Section";
  26. import { HelpDialog } from "./HelpDialog";
  27. import Stack from "./Stack";
  28. import { UserList } from "./UserList";
  29. import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
  30. import { JSONExportDialog } from "./JSONExportDialog";
  31. import { LibraryButton } from "./LibraryButton";
  32. import { isImageFileHandle } from "../data/blob";
  33. import { LibraryMenu } from "./LibraryMenu";
  34. import "./LayerUI.scss";
  35. import "./Toolbar.scss";
  36. import { PenModeButton } from "./PenModeButton";
  37. import { trackEvent } from "../analytics";
  38. import { useDevice } from "../components/App";
  39. import { Stats } from "./Stats";
  40. import { actionToggleStats } from "../actions/actionToggleStats";
  41. import { actionToggleZenMode } from "../actions";
  42. interface LayerUIProps {
  43. actionManager: ActionManager;
  44. appState: AppState;
  45. files: BinaryFiles;
  46. canvas: HTMLCanvasElement | null;
  47. setAppState: React.Component<any, AppState>["setState"];
  48. elements: readonly NonDeletedExcalidrawElement[];
  49. onCollabButtonClick?: () => void;
  50. onLockToggle: () => void;
  51. onPenModeToggle: () => void;
  52. onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
  53. showExitZenModeBtn: boolean;
  54. showThemeBtn: boolean;
  55. langCode: Language["code"];
  56. isCollaborating: boolean;
  57. renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
  58. renderCustomFooter?: ExcalidrawProps["renderFooter"];
  59. renderCustomStats?: ExcalidrawProps["renderCustomStats"];
  60. libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
  61. UIOptions: AppProps["UIOptions"];
  62. focusContainer: () => void;
  63. library: Library;
  64. id: string;
  65. onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
  66. }
  67. const LayerUI = ({
  68. actionManager,
  69. appState,
  70. files,
  71. setAppState,
  72. canvas,
  73. elements,
  74. onCollabButtonClick,
  75. onLockToggle,
  76. onPenModeToggle,
  77. onInsertElements,
  78. showExitZenModeBtn,
  79. showThemeBtn,
  80. isCollaborating,
  81. renderTopRightUI,
  82. renderCustomFooter,
  83. renderCustomStats,
  84. libraryReturnUrl,
  85. UIOptions,
  86. focusContainer,
  87. library,
  88. id,
  89. onImageAction,
  90. }: LayerUIProps) => {
  91. const device = useDevice();
  92. const renderJSONExportDialog = () => {
  93. if (!UIOptions.canvasActions.export) {
  94. return null;
  95. }
  96. return (
  97. <JSONExportDialog
  98. elements={elements}
  99. appState={appState}
  100. files={files}
  101. actionManager={actionManager}
  102. exportOpts={UIOptions.canvasActions.export}
  103. canvas={canvas}
  104. />
  105. );
  106. };
  107. const renderImageExportDialog = () => {
  108. if (!UIOptions.canvasActions.saveAsImage) {
  109. return null;
  110. }
  111. const createExporter =
  112. (type: ExportType): ExportCB =>
  113. async (exportedElements) => {
  114. trackEvent("export", type, "ui");
  115. const fileHandle = await exportCanvas(
  116. type,
  117. exportedElements,
  118. appState,
  119. files,
  120. {
  121. exportBackground: appState.exportBackground,
  122. name: appState.name,
  123. viewBackgroundColor: appState.viewBackgroundColor,
  124. },
  125. )
  126. .catch(muteFSAbortError)
  127. .catch((error) => {
  128. console.error(error);
  129. setAppState({ errorMessage: error.message });
  130. });
  131. if (
  132. appState.exportEmbedScene &&
  133. fileHandle &&
  134. isImageFileHandle(fileHandle)
  135. ) {
  136. setAppState({ fileHandle });
  137. }
  138. };
  139. return (
  140. <ImageExportDialog
  141. elements={elements}
  142. appState={appState}
  143. files={files}
  144. actionManager={actionManager}
  145. onExportToPng={createExporter("png")}
  146. onExportToSvg={createExporter("svg")}
  147. onExportToClipboard={createExporter("clipboard")}
  148. />
  149. );
  150. };
  151. const Separator = () => {
  152. return <div style={{ width: ".625em" }} />;
  153. };
  154. const renderViewModeCanvasActions = () => {
  155. return (
  156. <Section
  157. heading="canvasActions"
  158. className={clsx("zen-mode-transition", {
  159. "transition-left": appState.zenModeEnabled,
  160. })}
  161. >
  162. {/* the zIndex ensures this menu has higher stacking order,
  163. see https://github.com/excalidraw/excalidraw/pull/1445 */}
  164. <Island padding={2} style={{ zIndex: 1 }}>
  165. <Stack.Col gap={4}>
  166. <Stack.Row gap={1} justifyContent="space-between">
  167. {renderJSONExportDialog()}
  168. {renderImageExportDialog()}
  169. </Stack.Row>
  170. </Stack.Col>
  171. </Island>
  172. </Section>
  173. );
  174. };
  175. const renderCanvasActions = () => (
  176. <Section
  177. heading="canvasActions"
  178. className={clsx("zen-mode-transition", {
  179. "transition-left": appState.zenModeEnabled,
  180. })}
  181. >
  182. {/* the zIndex ensures this menu has higher stacking order,
  183. see https://github.com/excalidraw/excalidraw/pull/1445 */}
  184. <Island padding={2} style={{ zIndex: 1 }}>
  185. <Stack.Col gap={4}>
  186. <Stack.Row gap={1} justifyContent="space-between">
  187. {actionManager.renderAction("clearCanvas")}
  188. <Separator />
  189. {actionManager.renderAction("loadScene")}
  190. {renderJSONExportDialog()}
  191. {renderImageExportDialog()}
  192. <Separator />
  193. {onCollabButtonClick && (
  194. <CollabButton
  195. isCollaborating={isCollaborating}
  196. collaboratorCount={appState.collaborators.size}
  197. onClick={onCollabButtonClick}
  198. />
  199. )}
  200. </Stack.Row>
  201. <BackgroundPickerAndDarkModeToggle
  202. actionManager={actionManager}
  203. appState={appState}
  204. setAppState={setAppState}
  205. showThemeBtn={showThemeBtn}
  206. />
  207. {appState.fileHandle && (
  208. <>{actionManager.renderAction("saveToActiveFile")}</>
  209. )}
  210. </Stack.Col>
  211. </Island>
  212. </Section>
  213. );
  214. const renderSelectedShapeActions = () => (
  215. <Section
  216. heading="selectedShapeActions"
  217. className={clsx("zen-mode-transition", {
  218. "transition-left": appState.zenModeEnabled,
  219. })}
  220. >
  221. <Island
  222. className={CLASSES.SHAPE_ACTIONS_MENU}
  223. padding={2}
  224. style={{
  225. // we want to make sure this doesn't overflow so subtracting 200
  226. // which is approximately height of zoom footer and top left menu items with some buffer
  227. // if active file name is displayed, subtracting 248 to account for its height
  228. maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
  229. }}
  230. >
  231. <SelectedShapeActions
  232. appState={appState}
  233. elements={elements}
  234. renderAction={actionManager.renderAction}
  235. activeTool={appState.activeTool.type}
  236. />
  237. </Island>
  238. </Section>
  239. );
  240. const closeLibrary = useCallback(() => {
  241. const isDialogOpen = !!document.querySelector(".Dialog");
  242. // Prevent closing if any dialog is open
  243. if (isDialogOpen) {
  244. return;
  245. }
  246. setAppState({ isLibraryOpen: false });
  247. }, [setAppState]);
  248. const deselectItems = useCallback(() => {
  249. setAppState({
  250. selectedElementIds: {},
  251. selectedGroupIds: {},
  252. });
  253. }, [setAppState]);
  254. const libraryMenu = appState.isLibraryOpen ? (
  255. <LibraryMenu
  256. pendingElements={getSelectedElements(elements, appState, true)}
  257. onClose={closeLibrary}
  258. onInsertLibraryItems={(libraryItems) => {
  259. onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
  260. }}
  261. onAddToLibrary={deselectItems}
  262. setAppState={setAppState}
  263. libraryReturnUrl={libraryReturnUrl}
  264. focusContainer={focusContainer}
  265. library={library}
  266. theme={appState.theme}
  267. files={files}
  268. id={id}
  269. appState={appState}
  270. />
  271. ) : null;
  272. const renderFixedSideContainer = () => {
  273. const shouldRenderSelectedShapeActions = showSelectedShapeActions(
  274. appState,
  275. elements,
  276. );
  277. return (
  278. <FixedSideContainer side="top">
  279. <div className="App-menu App-menu_top">
  280. <Stack.Col
  281. gap={4}
  282. className={clsx({
  283. "disable-pointerEvents": appState.zenModeEnabled,
  284. })}
  285. >
  286. {appState.viewModeEnabled
  287. ? renderViewModeCanvasActions()
  288. : renderCanvasActions()}
  289. {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
  290. </Stack.Col>
  291. {!appState.viewModeEnabled && (
  292. <Section heading="shapes">
  293. {(heading: React.ReactNode) => (
  294. <Stack.Col gap={4} align="start">
  295. <Stack.Row
  296. gap={1}
  297. className={clsx("App-toolbar-container", {
  298. "zen-mode": appState.zenModeEnabled,
  299. })}
  300. >
  301. <PenModeButton
  302. zenModeEnabled={appState.zenModeEnabled}
  303. checked={appState.penMode}
  304. onChange={onPenModeToggle}
  305. title={t("toolBar.penMode")}
  306. penDetected={appState.penDetected}
  307. />
  308. <LockButton
  309. zenModeEnabled={appState.zenModeEnabled}
  310. checked={appState.activeTool.locked}
  311. onChange={() => onLockToggle()}
  312. title={t("toolBar.lock")}
  313. />
  314. <Island
  315. padding={1}
  316. className={clsx("App-toolbar", {
  317. "zen-mode": appState.zenModeEnabled,
  318. })}
  319. >
  320. <HintViewer
  321. appState={appState}
  322. elements={elements}
  323. isMobile={device.isMobile}
  324. />
  325. {heading}
  326. <Stack.Row gap={1}>
  327. <ShapesSwitcher
  328. appState={appState}
  329. canvas={canvas}
  330. activeTool={appState.activeTool}
  331. setAppState={setAppState}
  332. onImageAction={({ pointerType }) => {
  333. onImageAction({
  334. insertOnCanvasDirectly: pointerType !== "mouse",
  335. });
  336. }}
  337. />
  338. </Stack.Row>
  339. </Island>
  340. <LibraryButton
  341. appState={appState}
  342. setAppState={setAppState}
  343. />
  344. </Stack.Row>
  345. </Stack.Col>
  346. )}
  347. </Section>
  348. )}
  349. <div
  350. className={clsx(
  351. "layer-ui__wrapper__top-right zen-mode-transition",
  352. {
  353. "transition-right": appState.zenModeEnabled,
  354. },
  355. )}
  356. >
  357. <UserList
  358. collaborators={appState.collaborators}
  359. actionManager={actionManager}
  360. />
  361. {renderTopRightUI?.(device.isMobile, appState)}
  362. </div>
  363. </div>
  364. </FixedSideContainer>
  365. );
  366. };
  367. const renderBottomAppMenu = () => {
  368. return (
  369. <footer
  370. role="contentinfo"
  371. className="layer-ui__wrapper__footer App-menu App-menu_bottom"
  372. >
  373. <div
  374. className={clsx(
  375. "layer-ui__wrapper__footer-left zen-mode-transition",
  376. {
  377. "layer-ui__wrapper__footer-left--transition-left":
  378. appState.zenModeEnabled,
  379. },
  380. )}
  381. >
  382. <Stack.Col gap={2}>
  383. <Section heading="canvasActions">
  384. <Island padding={1}>
  385. <ZoomActions
  386. renderAction={actionManager.renderAction}
  387. zoom={appState.zoom}
  388. />
  389. </Island>
  390. {!appState.viewModeEnabled && (
  391. <>
  392. <div
  393. className={clsx("undo-redo-buttons zen-mode-transition", {
  394. "layer-ui__wrapper__footer-left--transition-bottom":
  395. appState.zenModeEnabled,
  396. })}
  397. >
  398. {actionManager.renderAction("undo", { size: "small" })}
  399. {actionManager.renderAction("redo", { size: "small" })}
  400. </div>
  401. <div
  402. className={clsx("eraser-buttons zen-mode-transition", {
  403. "layer-ui__wrapper__footer-left--transition-left":
  404. appState.zenModeEnabled,
  405. })}
  406. >
  407. {actionManager.renderAction("eraser", { size: "small" })}
  408. </div>
  409. </>
  410. )}
  411. {!appState.viewModeEnabled &&
  412. appState.multiElement &&
  413. device.isTouchScreen && (
  414. <div
  415. className={clsx("finalize-button zen-mode-transition", {
  416. "layer-ui__wrapper__footer-left--transition-left":
  417. appState.zenModeEnabled,
  418. })}
  419. >
  420. {actionManager.renderAction("finalize", { size: "small" })}
  421. </div>
  422. )}
  423. </Section>
  424. </Stack.Col>
  425. </div>
  426. <div
  427. className={clsx(
  428. "layer-ui__wrapper__footer-center zen-mode-transition",
  429. {
  430. "layer-ui__wrapper__footer-left--transition-bottom":
  431. appState.zenModeEnabled,
  432. },
  433. )}
  434. >
  435. {renderCustomFooter?.(false, appState)}
  436. </div>
  437. <div
  438. className={clsx(
  439. "layer-ui__wrapper__footer-right zen-mode-transition",
  440. {
  441. "transition-right disable-pointerEvents": appState.zenModeEnabled,
  442. },
  443. )}
  444. >
  445. {actionManager.renderAction("toggleShortcuts")}
  446. </div>
  447. <button
  448. className={clsx("disable-zen-mode", {
  449. "disable-zen-mode--visible": showExitZenModeBtn,
  450. })}
  451. onClick={() => actionManager.executeAction(actionToggleZenMode)}
  452. >
  453. {t("buttons.exitZenMode")}
  454. </button>
  455. </footer>
  456. );
  457. };
  458. const dialogs = (
  459. <>
  460. {appState.isLoading && <LoadingMessage delay={250} />}
  461. {appState.errorMessage && (
  462. <ErrorDialog
  463. message={appState.errorMessage}
  464. onClose={() => setAppState({ errorMessage: null })}
  465. />
  466. )}
  467. {appState.showHelpDialog && (
  468. <HelpDialog
  469. onClose={() => {
  470. setAppState({ showHelpDialog: false });
  471. }}
  472. />
  473. )}
  474. {appState.pasteDialog.shown && (
  475. <PasteChartDialog
  476. setAppState={setAppState}
  477. appState={appState}
  478. onInsertChart={onInsertElements}
  479. onClose={() =>
  480. setAppState({
  481. pasteDialog: { shown: false, data: null },
  482. })
  483. }
  484. />
  485. )}
  486. </>
  487. );
  488. const renderStats = () => {
  489. if (!appState.showStats) {
  490. return null;
  491. }
  492. return (
  493. <Stats
  494. appState={appState}
  495. setAppState={setAppState}
  496. elements={elements}
  497. onClose={() => {
  498. actionManager.executeAction(actionToggleStats);
  499. }}
  500. renderCustomStats={renderCustomStats}
  501. />
  502. );
  503. };
  504. return device.isMobile ? (
  505. <>
  506. {dialogs}
  507. <MobileMenu
  508. appState={appState}
  509. elements={elements}
  510. actionManager={actionManager}
  511. libraryMenu={libraryMenu}
  512. renderJSONExportDialog={renderJSONExportDialog}
  513. renderImageExportDialog={renderImageExportDialog}
  514. setAppState={setAppState}
  515. onCollabButtonClick={onCollabButtonClick}
  516. onLockToggle={() => onLockToggle()}
  517. onPenModeToggle={onPenModeToggle}
  518. canvas={canvas}
  519. isCollaborating={isCollaborating}
  520. renderCustomFooter={renderCustomFooter}
  521. showThemeBtn={showThemeBtn}
  522. onImageAction={onImageAction}
  523. renderTopRightUI={renderTopRightUI}
  524. renderStats={renderStats}
  525. />
  526. </>
  527. ) : (
  528. <>
  529. <div
  530. className={clsx("layer-ui__wrapper", {
  531. "disable-pointerEvents":
  532. appState.draggingElement ||
  533. appState.resizingElement ||
  534. (appState.editingElement &&
  535. !isTextElement(appState.editingElement)),
  536. })}
  537. style={
  538. appState.isLibraryOpen &&
  539. appState.isLibraryMenuDocked &&
  540. device.canDeviceFitSidebar
  541. ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
  542. : {}
  543. }
  544. >
  545. {dialogs}
  546. {renderFixedSideContainer()}
  547. {renderBottomAppMenu()}
  548. {renderStats()}
  549. {appState.scrolledOutside && (
  550. <button
  551. className="scroll-back-to-content"
  552. onClick={() => {
  553. setAppState({
  554. ...calculateScrollCenter(elements, appState, canvas),
  555. });
  556. }}
  557. >
  558. {t("buttons.scrollBackToContent")}
  559. </button>
  560. )}
  561. </div>
  562. {appState.isLibraryOpen && (
  563. <div className="layer-ui__sidebar">{libraryMenu}</div>
  564. )}
  565. </>
  566. );
  567. };
  568. const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
  569. const getNecessaryObj = (appState: AppState): Partial<AppState> => {
  570. const {
  571. suggestedBindings,
  572. startBoundElement: boundElement,
  573. ...ret
  574. } = appState;
  575. return ret;
  576. };
  577. const prevAppState = getNecessaryObj(prev.appState);
  578. const nextAppState = getNecessaryObj(next.appState);
  579. const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
  580. return (
  581. prev.renderCustomFooter === next.renderCustomFooter &&
  582. prev.langCode === next.langCode &&
  583. prev.elements === next.elements &&
  584. prev.files === next.files &&
  585. keys.every((key) => prevAppState[key] === nextAppState[key])
  586. );
  587. };
  588. export default React.memo(LayerUI, areEqual);