LayerUI.tsx 19 KB

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