LayerUI.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. import clsx from "clsx";
  2. import React 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 } 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 } from "./Actions";
  14. import CollabButton from "./CollabButton";
  15. import { ErrorDialog } from "./ErrorDialog";
  16. import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
  17. import { FixedSideContainer } from "./FixedSideContainer";
  18. import { HintViewer } from "./HintViewer";
  19. import { Island } from "./Island";
  20. import { LoadingMessage } from "./LoadingMessage";
  21. import { LockButton } from "./LockButton";
  22. import { MobileMenu } from "./MobileMenu";
  23. import { PasteChartDialog } from "./PasteChartDialog";
  24. import { Section } from "./Section";
  25. import { HelpDialog } from "./HelpDialog";
  26. import Stack from "./Stack";
  27. import { UserList } from "./UserList";
  28. import Library from "../data/library";
  29. import { JSONExportDialog } from "./JSONExportDialog";
  30. import { LibraryButton } from "./LibraryButton";
  31. import { isImageFileHandle } from "../data/blob";
  32. import { LibraryMenu } from "./LibraryMenu";
  33. import "./LayerUI.scss";
  34. import "./Toolbar.scss";
  35. import { PenModeButton } from "./PenModeButton";
  36. import { trackEvent } from "../analytics";
  37. import { isMenuOpenAtom, useDevice } from "../components/App";
  38. import { Stats } from "./Stats";
  39. import { actionToggleStats } from "../actions/actionToggleStats";
  40. import Footer from "./Footer";
  41. import {
  42. ExportImageIcon,
  43. HamburgerMenuIcon,
  44. WelcomeScreenMenuArrow,
  45. WelcomeScreenTopToolbarArrow,
  46. } from "./icons";
  47. import { MenuLinks, Separator } from "./MenuUtils";
  48. import { useOutsideClickHook } from "../hooks/useOutsideClick";
  49. import WelcomeScreen from "./WelcomeScreen";
  50. import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
  51. import { jotaiScope } from "../jotai";
  52. import { useAtom } from "jotai";
  53. import { LanguageList } from "../excalidraw-app/components/LanguageList";
  54. import WelcomeScreenDecor from "./WelcomeScreenDecor";
  55. import { getShortcutFromShortcutName } from "../actions/shortcuts";
  56. import MenuItem from "./MenuItem";
  57. interface LayerUIProps {
  58. actionManager: ActionManager;
  59. appState: AppState;
  60. files: BinaryFiles;
  61. canvas: HTMLCanvasElement | null;
  62. setAppState: React.Component<any, AppState>["setState"];
  63. elements: readonly NonDeletedExcalidrawElement[];
  64. onCollabButtonClick?: () => void;
  65. onLockToggle: () => void;
  66. onPenModeToggle: () => void;
  67. onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
  68. showExitZenModeBtn: boolean;
  69. langCode: Language["code"];
  70. isCollaborating: boolean;
  71. renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
  72. renderCustomFooter?: ExcalidrawProps["renderFooter"];
  73. renderCustomStats?: ExcalidrawProps["renderCustomStats"];
  74. renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
  75. libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
  76. UIOptions: AppProps["UIOptions"];
  77. focusContainer: () => void;
  78. library: Library;
  79. id: string;
  80. onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
  81. renderWelcomeScreen: boolean;
  82. }
  83. const LayerUI = ({
  84. actionManager,
  85. appState,
  86. files,
  87. setAppState,
  88. elements,
  89. canvas,
  90. onCollabButtonClick,
  91. onLockToggle,
  92. onPenModeToggle,
  93. onInsertElements,
  94. showExitZenModeBtn,
  95. isCollaborating,
  96. renderTopRightUI,
  97. renderCustomFooter,
  98. renderCustomStats,
  99. renderCustomSidebar,
  100. libraryReturnUrl,
  101. UIOptions,
  102. focusContainer,
  103. library,
  104. id,
  105. onImageAction,
  106. renderWelcomeScreen,
  107. }: LayerUIProps) => {
  108. const device = useDevice();
  109. const renderJSONExportDialog = () => {
  110. if (!UIOptions.canvasActions.export) {
  111. return null;
  112. }
  113. return (
  114. <JSONExportDialog
  115. elements={elements}
  116. appState={appState}
  117. files={files}
  118. actionManager={actionManager}
  119. exportOpts={UIOptions.canvasActions.export}
  120. canvas={canvas}
  121. />
  122. );
  123. };
  124. const renderImageExportDialog = () => {
  125. if (!UIOptions.canvasActions.saveAsImage) {
  126. return null;
  127. }
  128. const createExporter =
  129. (type: ExportType): ExportCB =>
  130. async (exportedElements) => {
  131. trackEvent("export", type, "ui");
  132. const fileHandle = await exportCanvas(
  133. type,
  134. exportedElements,
  135. appState,
  136. files,
  137. {
  138. exportBackground: appState.exportBackground,
  139. name: appState.name,
  140. viewBackgroundColor: appState.viewBackgroundColor,
  141. },
  142. )
  143. .catch(muteFSAbortError)
  144. .catch((error) => {
  145. console.error(error);
  146. setAppState({ errorMessage: error.message });
  147. });
  148. if (
  149. appState.exportEmbedScene &&
  150. fileHandle &&
  151. isImageFileHandle(fileHandle)
  152. ) {
  153. setAppState({ fileHandle });
  154. }
  155. };
  156. return (
  157. <ImageExportDialog
  158. elements={elements}
  159. appState={appState}
  160. setAppState={setAppState}
  161. files={files}
  162. actionManager={actionManager}
  163. onExportToPng={createExporter("png")}
  164. onExportToSvg={createExporter("svg")}
  165. onExportToClipboard={createExporter("clipboard")}
  166. />
  167. );
  168. };
  169. const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom);
  170. const menuRef = useOutsideClickHook(() => setIsMenuOpen(false));
  171. const renderCanvasActions = () => (
  172. <div style={{ position: "relative" }}>
  173. <WelcomeScreenDecor
  174. shouldRender={renderWelcomeScreen && !appState.isLoading}
  175. >
  176. <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--menu-pointer">
  177. {WelcomeScreenMenuArrow}
  178. <div>{t("welcomeScreen.menuHints")}</div>
  179. </div>
  180. </WelcomeScreenDecor>
  181. <button
  182. data-prevent-outside-click
  183. className={clsx("menu-button", "zen-mode-transition", {
  184. "transition-left": appState.zenModeEnabled,
  185. })}
  186. onClick={() => setIsMenuOpen(!isMenuOpen)}
  187. type="button"
  188. data-testid="menu-button"
  189. >
  190. {HamburgerMenuIcon}
  191. </button>
  192. {isMenuOpen && (
  193. <div
  194. ref={menuRef}
  195. style={{ position: "absolute", top: "100%", marginTop: ".25rem" }}
  196. >
  197. <Section heading="canvasActions">
  198. {/* the zIndex ensures this menu has higher stacking order,
  199. see https://github.com/excalidraw/excalidraw/pull/1445 */}
  200. <Island
  201. className="menu-container"
  202. padding={2}
  203. style={{ zIndex: 1 }}
  204. >
  205. {!appState.viewModeEnabled &&
  206. actionManager.renderAction("loadScene")}
  207. {/* // TODO barnabasmolnar/editor-redesign */}
  208. {/* is this fine here? */}
  209. {appState.fileHandle &&
  210. actionManager.renderAction("saveToActiveFile")}
  211. {renderJSONExportDialog()}
  212. {UIOptions.canvasActions.saveAsImage && (
  213. <MenuItem
  214. label={t("buttons.exportImage")}
  215. icon={ExportImageIcon}
  216. dataTestId="image-export-button"
  217. onClick={() => setAppState({ openDialog: "imageExport" })}
  218. shortcut={getShortcutFromShortcutName("imageExport")}
  219. />
  220. )}
  221. {onCollabButtonClick && (
  222. <CollabButton
  223. isCollaborating={isCollaborating}
  224. collaboratorCount={appState.collaborators.size}
  225. onClick={onCollabButtonClick}
  226. />
  227. )}
  228. {actionManager.renderAction("toggleShortcuts", undefined, true)}
  229. {!appState.viewModeEnabled &&
  230. actionManager.renderAction("clearCanvas")}
  231. <Separator />
  232. <MenuLinks />
  233. <Separator />
  234. <div
  235. style={{
  236. display: "flex",
  237. flexDirection: "column",
  238. rowGap: ".5rem",
  239. }}
  240. >
  241. <div>{actionManager.renderAction("toggleTheme")}</div>
  242. <div style={{ padding: "0 0.625rem" }}>
  243. <LanguageList style={{ width: "100%" }} />
  244. </div>
  245. {!appState.viewModeEnabled && (
  246. <div>
  247. <div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
  248. {t("labels.canvasBackground")}
  249. </div>
  250. <div style={{ padding: "0 0.625rem" }}>
  251. {actionManager.renderAction("changeViewBackgroundColor")}
  252. </div>
  253. </div>
  254. )}
  255. </div>
  256. </Island>
  257. </Section>
  258. </div>
  259. )}
  260. </div>
  261. );
  262. const renderSelectedShapeActions = () => (
  263. <Section
  264. heading="selectedShapeActions"
  265. className={clsx("selected-shape-actions zen-mode-transition", {
  266. "transition-left": appState.zenModeEnabled,
  267. })}
  268. >
  269. <Island
  270. className={CLASSES.SHAPE_ACTIONS_MENU}
  271. padding={2}
  272. style={{
  273. // we want to make sure this doesn't overflow so subtracting the
  274. // approximate height of hamburgerMenu + footer
  275. maxHeight: `${appState.height - 166}px`,
  276. }}
  277. >
  278. <SelectedShapeActions
  279. appState={appState}
  280. elements={elements}
  281. renderAction={actionManager.renderAction}
  282. />
  283. </Island>
  284. </Section>
  285. );
  286. const renderFixedSideContainer = () => {
  287. const shouldRenderSelectedShapeActions = showSelectedShapeActions(
  288. appState,
  289. elements,
  290. );
  291. return (
  292. <FixedSideContainer side="top">
  293. {renderWelcomeScreen && !appState.isLoading && (
  294. <WelcomeScreen appState={appState} actionManager={actionManager} />
  295. )}
  296. <div className="App-menu App-menu_top">
  297. <Stack.Col
  298. gap={6}
  299. className={clsx("App-menu_top__left", {
  300. "disable-pointerEvents": appState.zenModeEnabled,
  301. })}
  302. >
  303. {renderCanvasActions()}
  304. {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
  305. </Stack.Col>
  306. {!appState.viewModeEnabled && (
  307. <Section heading="shapes" className="shapes-section">
  308. {(heading: React.ReactNode) => (
  309. <div style={{ position: "relative" }}>
  310. <WelcomeScreenDecor
  311. shouldRender={renderWelcomeScreen && !appState.isLoading}
  312. >
  313. <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--top-toolbar-pointer">
  314. <div className="WelcomeScreen-decor--top-toolbar-pointer__label">
  315. {t("welcomeScreen.toolbarHints")}
  316. </div>
  317. {WelcomeScreenTopToolbarArrow}
  318. </div>
  319. </WelcomeScreenDecor>
  320. <Stack.Col gap={4} align="start">
  321. <Stack.Row
  322. gap={1}
  323. className={clsx("App-toolbar-container", {
  324. "zen-mode": appState.zenModeEnabled,
  325. })}
  326. >
  327. <Island
  328. padding={1}
  329. className={clsx("App-toolbar", {
  330. "zen-mode": appState.zenModeEnabled,
  331. })}
  332. >
  333. <HintViewer
  334. appState={appState}
  335. elements={elements}
  336. isMobile={device.isMobile}
  337. device={device}
  338. />
  339. {heading}
  340. <Stack.Row gap={1}>
  341. <PenModeButton
  342. zenModeEnabled={appState.zenModeEnabled}
  343. checked={appState.penMode}
  344. onChange={onPenModeToggle}
  345. title={t("toolBar.penMode")}
  346. penDetected={appState.penDetected}
  347. />
  348. <LockButton
  349. zenModeEnabled={appState.zenModeEnabled}
  350. checked={appState.activeTool.locked}
  351. onChange={() => onLockToggle()}
  352. title={t("toolBar.lock")}
  353. />
  354. <div className="App-toolbar__divider"></div>
  355. <ShapesSwitcher
  356. appState={appState}
  357. canvas={canvas}
  358. activeTool={appState.activeTool}
  359. setAppState={setAppState}
  360. onImageAction={({ pointerType }) => {
  361. onImageAction({
  362. insertOnCanvasDirectly: pointerType !== "mouse",
  363. });
  364. }}
  365. />
  366. {/* {actionManager.renderAction("eraser", {
  367. // size: "small",
  368. })} */}
  369. </Stack.Row>
  370. </Island>
  371. </Stack.Row>
  372. </Stack.Col>
  373. </div>
  374. )}
  375. </Section>
  376. )}
  377. <div
  378. className={clsx(
  379. "layer-ui__wrapper__top-right zen-mode-transition",
  380. {
  381. "transition-right": appState.zenModeEnabled,
  382. },
  383. )}
  384. >
  385. <UserList
  386. collaborators={appState.collaborators}
  387. actionManager={actionManager}
  388. />
  389. {onCollabButtonClick && (
  390. <CollabButton
  391. isInHamburgerMenu={false}
  392. isCollaborating={isCollaborating}
  393. collaboratorCount={appState.collaborators.size}
  394. onClick={onCollabButtonClick}
  395. />
  396. )}
  397. {renderTopRightUI?.(device.isMobile, appState)}
  398. {!appState.viewModeEnabled && (
  399. <LibraryButton appState={appState} setAppState={setAppState} />
  400. )}
  401. </div>
  402. </div>
  403. </FixedSideContainer>
  404. );
  405. };
  406. const renderSidebars = () => {
  407. return appState.openSidebar === "customSidebar" ? (
  408. renderCustomSidebar?.() || null
  409. ) : appState.openSidebar === "library" ? (
  410. <LibraryMenu
  411. appState={appState}
  412. onInsertElements={onInsertElements}
  413. libraryReturnUrl={libraryReturnUrl}
  414. focusContainer={focusContainer}
  415. library={library}
  416. id={id}
  417. />
  418. ) : null;
  419. };
  420. const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
  421. return (
  422. <>
  423. {appState.isLoading && <LoadingMessage delay={250} />}
  424. {appState.errorMessage && (
  425. <ErrorDialog
  426. message={appState.errorMessage}
  427. onClose={() => setAppState({ errorMessage: null })}
  428. />
  429. )}
  430. {appState.openDialog === "help" && (
  431. <HelpDialog
  432. onClose={() => {
  433. setAppState({ openDialog: null });
  434. }}
  435. />
  436. )}
  437. {renderImageExportDialog()}
  438. {appState.pasteDialog.shown && (
  439. <PasteChartDialog
  440. setAppState={setAppState}
  441. appState={appState}
  442. onInsertChart={onInsertElements}
  443. onClose={() =>
  444. setAppState({
  445. pasteDialog: { shown: false, data: null },
  446. })
  447. }
  448. />
  449. )}
  450. {device.isMobile && (
  451. <MobileMenu
  452. renderWelcomeScreen={renderWelcomeScreen}
  453. appState={appState}
  454. elements={elements}
  455. actionManager={actionManager}
  456. renderJSONExportDialog={renderJSONExportDialog}
  457. renderImageExportDialog={renderImageExportDialog}
  458. setAppState={setAppState}
  459. onCollabButtonClick={onCollabButtonClick}
  460. onLockToggle={() => onLockToggle()}
  461. onPenModeToggle={onPenModeToggle}
  462. canvas={canvas}
  463. isCollaborating={isCollaborating}
  464. renderCustomFooter={renderCustomFooter}
  465. onImageAction={onImageAction}
  466. renderTopRightUI={renderTopRightUI}
  467. renderCustomStats={renderCustomStats}
  468. renderSidebars={renderSidebars}
  469. device={device}
  470. />
  471. )}
  472. {!device.isMobile && (
  473. <>
  474. <div
  475. className={clsx("layer-ui__wrapper", {
  476. "disable-pointerEvents":
  477. appState.draggingElement ||
  478. appState.resizingElement ||
  479. (appState.editingElement &&
  480. !isTextElement(appState.editingElement)),
  481. })}
  482. style={
  483. ((appState.openSidebar === "library" &&
  484. appState.isSidebarDocked) ||
  485. hostSidebarCounters.docked) &&
  486. device.canDeviceFitSidebar
  487. ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
  488. : {}
  489. }
  490. >
  491. {renderFixedSideContainer()}
  492. <Footer
  493. renderWelcomeScreen={renderWelcomeScreen}
  494. appState={appState}
  495. actionManager={actionManager}
  496. renderCustomFooter={renderCustomFooter}
  497. showExitZenModeBtn={showExitZenModeBtn}
  498. />
  499. {appState.showStats && (
  500. <Stats
  501. appState={appState}
  502. setAppState={setAppState}
  503. elements={elements}
  504. onClose={() => {
  505. actionManager.executeAction(actionToggleStats);
  506. }}
  507. renderCustomStats={renderCustomStats}
  508. />
  509. )}
  510. {appState.scrolledOutside && (
  511. <button
  512. className="scroll-back-to-content"
  513. onClick={() => {
  514. setAppState({
  515. ...calculateScrollCenter(elements, appState, canvas),
  516. });
  517. }}
  518. >
  519. {t("buttons.scrollBackToContent")}
  520. </button>
  521. )}
  522. </div>
  523. {renderSidebars()}
  524. </>
  525. )}
  526. </>
  527. );
  528. };
  529. const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
  530. const getNecessaryObj = (appState: AppState): Partial<AppState> => {
  531. const {
  532. suggestedBindings,
  533. startBoundElement: boundElement,
  534. ...ret
  535. } = appState;
  536. return ret;
  537. };
  538. const prevAppState = getNecessaryObj(prev.appState);
  539. const nextAppState = getNecessaryObj(next.appState);
  540. const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
  541. return (
  542. prev.renderCustomFooter === next.renderCustomFooter &&
  543. prev.renderTopRightUI === next.renderTopRightUI &&
  544. prev.renderCustomStats === next.renderCustomStats &&
  545. prev.renderCustomSidebar === next.renderCustomSidebar &&
  546. prev.langCode === next.langCode &&
  547. prev.elements === next.elements &&
  548. prev.files === next.files &&
  549. keys.every((key) => prevAppState[key] === nextAppState[key])
  550. );
  551. };
  552. export default React.memo(LayerUI, areEqual);