LayerUI.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  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 { isShallowEqual, muteFSAbortError } from "../utils";
  13. import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
  14. import { ErrorDialog } from "./ErrorDialog";
  15. import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
  16. import { FixedSideContainer } from "./FixedSideContainer";
  17. import { HintViewer } from "./HintViewer";
  18. import { Island } from "./Island";
  19. import { LoadingMessage } from "./LoadingMessage";
  20. import { LockButton } from "./LockButton";
  21. import { MobileMenu } from "./MobileMenu";
  22. import { PasteChartDialog } from "./PasteChartDialog";
  23. import { Section } from "./Section";
  24. import { HelpDialog } from "./HelpDialog";
  25. import Stack from "./Stack";
  26. import { UserList } from "./UserList";
  27. import Library from "../data/library";
  28. import { JSONExportDialog } from "./JSONExportDialog";
  29. import { LibraryButton } from "./LibraryButton";
  30. import { isImageFileHandle } from "../data/blob";
  31. import { LibraryMenu } from "./LibraryMenu";
  32. import "./LayerUI.scss";
  33. import "./Toolbar.scss";
  34. import { PenModeButton } from "./PenModeButton";
  35. import { trackEvent } from "../analytics";
  36. import { useDevice } from "../components/App";
  37. import { Stats } from "./Stats";
  38. import { actionToggleStats } from "../actions/actionToggleStats";
  39. import Footer from "./footer/Footer";
  40. import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
  41. import { jotaiScope } from "../jotai";
  42. import { useAtom } from "jotai";
  43. import MainMenu from "./main-menu/MainMenu";
  44. import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
  45. import { HandButton } from "./HandButton";
  46. import { isHandToolActive } from "../appState";
  47. import {
  48. mainMenuTunnel,
  49. welcomeScreenMenuHintTunnel,
  50. welcomeScreenToolbarHintTunnel,
  51. welcomeScreenCenterTunnel,
  52. } from "./tunnels";
  53. interface LayerUIProps {
  54. actionManager: ActionManager;
  55. appState: AppState;
  56. files: BinaryFiles;
  57. canvas: HTMLCanvasElement | null;
  58. setAppState: React.Component<any, AppState>["setState"];
  59. elements: readonly NonDeletedExcalidrawElement[];
  60. onLockToggle: () => void;
  61. onHandToolToggle: () => void;
  62. onPenModeToggle: () => void;
  63. onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
  64. showExitZenModeBtn: boolean;
  65. langCode: Language["code"];
  66. renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
  67. renderCustomStats?: ExcalidrawProps["renderCustomStats"];
  68. renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
  69. libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
  70. UIOptions: AppProps["UIOptions"];
  71. focusContainer: () => void;
  72. library: Library;
  73. id: string;
  74. onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
  75. renderWelcomeScreen: boolean;
  76. children?: React.ReactNode;
  77. }
  78. const DefaultMainMenu: React.FC<{
  79. UIOptions: AppProps["UIOptions"];
  80. }> = ({ UIOptions }) => {
  81. return (
  82. <MainMenu __fallback>
  83. <MainMenu.DefaultItems.LoadScene />
  84. <MainMenu.DefaultItems.SaveToActiveFile />
  85. {/* FIXME we should to test for this inside the item itself */}
  86. {UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
  87. {/* FIXME we should to test for this inside the item itself */}
  88. {UIOptions.canvasActions.saveAsImage && (
  89. <MainMenu.DefaultItems.SaveAsImage />
  90. )}
  91. <MainMenu.DefaultItems.Help />
  92. <MainMenu.DefaultItems.ClearCanvas />
  93. <MainMenu.Separator />
  94. <MainMenu.Group title="Excalidraw links">
  95. <MainMenu.DefaultItems.Socials />
  96. </MainMenu.Group>
  97. <MainMenu.Separator />
  98. <MainMenu.DefaultItems.ToggleTheme />
  99. <MainMenu.DefaultItems.ChangeCanvasBackground />
  100. </MainMenu>
  101. );
  102. };
  103. const LayerUI = ({
  104. actionManager,
  105. appState,
  106. files,
  107. setAppState,
  108. elements,
  109. canvas,
  110. onLockToggle,
  111. onHandToolToggle,
  112. onPenModeToggle,
  113. onInsertElements,
  114. showExitZenModeBtn,
  115. renderTopRightUI,
  116. renderCustomStats,
  117. renderCustomSidebar,
  118. libraryReturnUrl,
  119. UIOptions,
  120. focusContainer,
  121. library,
  122. id,
  123. onImageAction,
  124. renderWelcomeScreen,
  125. children,
  126. }: LayerUIProps) => {
  127. const device = useDevice();
  128. const renderJSONExportDialog = () => {
  129. if (!UIOptions.canvasActions.export) {
  130. return null;
  131. }
  132. return (
  133. <JSONExportDialog
  134. elements={elements}
  135. appState={appState}
  136. files={files}
  137. actionManager={actionManager}
  138. exportOpts={UIOptions.canvasActions.export}
  139. canvas={canvas}
  140. setAppState={setAppState}
  141. />
  142. );
  143. };
  144. const renderImageExportDialog = () => {
  145. if (!UIOptions.canvasActions.saveAsImage) {
  146. return null;
  147. }
  148. const createExporter =
  149. (type: ExportType): ExportCB =>
  150. async (exportedElements) => {
  151. trackEvent("export", type, "ui");
  152. const fileHandle = await exportCanvas(
  153. type,
  154. exportedElements,
  155. appState,
  156. files,
  157. {
  158. exportBackground: appState.exportBackground,
  159. name: appState.name,
  160. viewBackgroundColor: appState.viewBackgroundColor,
  161. },
  162. )
  163. .catch(muteFSAbortError)
  164. .catch((error) => {
  165. console.error(error);
  166. setAppState({ errorMessage: error.message });
  167. });
  168. if (
  169. appState.exportEmbedScene &&
  170. fileHandle &&
  171. isImageFileHandle(fileHandle)
  172. ) {
  173. setAppState({ fileHandle });
  174. }
  175. };
  176. return (
  177. <ImageExportDialog
  178. elements={elements}
  179. appState={appState}
  180. setAppState={setAppState}
  181. files={files}
  182. actionManager={actionManager}
  183. onExportToPng={createExporter("png")}
  184. onExportToSvg={createExporter("svg")}
  185. onExportToClipboard={createExporter("clipboard")}
  186. />
  187. );
  188. };
  189. const renderCanvasActions = () => (
  190. <div style={{ position: "relative" }}>
  191. {/* wrapping to Fragment stops React from occasionally complaining
  192. about identical Keys */}
  193. <mainMenuTunnel.Out />
  194. {renderWelcomeScreen && <welcomeScreenMenuHintTunnel.Out />}
  195. </div>
  196. );
  197. const renderSelectedShapeActions = () => (
  198. <Section
  199. heading="selectedShapeActions"
  200. className={clsx("selected-shape-actions zen-mode-transition", {
  201. "transition-left": appState.zenModeEnabled,
  202. })}
  203. >
  204. <Island
  205. className={CLASSES.SHAPE_ACTIONS_MENU}
  206. padding={2}
  207. style={{
  208. // we want to make sure this doesn't overflow so subtracting the
  209. // approximate height of hamburgerMenu + footer
  210. maxHeight: `${appState.height - 166}px`,
  211. }}
  212. >
  213. <SelectedShapeActions
  214. appState={appState}
  215. elements={elements}
  216. renderAction={actionManager.renderAction}
  217. />
  218. </Island>
  219. </Section>
  220. );
  221. const renderFixedSideContainer = () => {
  222. const shouldRenderSelectedShapeActions = showSelectedShapeActions(
  223. appState,
  224. elements,
  225. );
  226. return (
  227. <FixedSideContainer side="top">
  228. <div className="App-menu App-menu_top">
  229. <Stack.Col
  230. gap={6}
  231. className={clsx("App-menu_top__left", {
  232. "disable-pointerEvents": appState.zenModeEnabled,
  233. })}
  234. >
  235. {renderCanvasActions()}
  236. {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
  237. </Stack.Col>
  238. {!appState.viewModeEnabled && (
  239. <Section heading="shapes" className="shapes-section">
  240. {(heading: React.ReactNode) => (
  241. <div style={{ position: "relative" }}>
  242. {renderWelcomeScreen && (
  243. <welcomeScreenToolbarHintTunnel.Out />
  244. )}
  245. <Stack.Col gap={4} align="start">
  246. <Stack.Row
  247. gap={1}
  248. className={clsx("App-toolbar-container", {
  249. "zen-mode": appState.zenModeEnabled,
  250. })}
  251. >
  252. <Island
  253. padding={1}
  254. className={clsx("App-toolbar", {
  255. "zen-mode": appState.zenModeEnabled,
  256. })}
  257. >
  258. <HintViewer
  259. appState={appState}
  260. elements={elements}
  261. isMobile={device.isMobile}
  262. device={device}
  263. />
  264. {heading}
  265. <Stack.Row gap={1}>
  266. <PenModeButton
  267. zenModeEnabled={appState.zenModeEnabled}
  268. checked={appState.penMode}
  269. onChange={onPenModeToggle}
  270. title={t("toolBar.penMode")}
  271. penDetected={appState.penDetected}
  272. />
  273. <LockButton
  274. checked={appState.activeTool.locked}
  275. onChange={onLockToggle}
  276. title={t("toolBar.lock")}
  277. />
  278. <div className="App-toolbar__divider"></div>
  279. <HandButton
  280. checked={isHandToolActive(appState)}
  281. onChange={() => onHandToolToggle()}
  282. title={t("toolBar.hand")}
  283. isMobile
  284. />
  285. <ShapesSwitcher
  286. appState={appState}
  287. canvas={canvas}
  288. activeTool={appState.activeTool}
  289. setAppState={setAppState}
  290. onImageAction={({ pointerType }) => {
  291. onImageAction({
  292. insertOnCanvasDirectly: pointerType !== "mouse",
  293. });
  294. }}
  295. />
  296. </Stack.Row>
  297. </Island>
  298. </Stack.Row>
  299. </Stack.Col>
  300. </div>
  301. )}
  302. </Section>
  303. )}
  304. <div
  305. className={clsx(
  306. "layer-ui__wrapper__top-right zen-mode-transition",
  307. {
  308. "transition-right": appState.zenModeEnabled,
  309. },
  310. )}
  311. >
  312. <UserList collaborators={appState.collaborators} />
  313. {renderTopRightUI?.(device.isMobile, appState)}
  314. {!appState.viewModeEnabled && (
  315. <LibraryButton appState={appState} setAppState={setAppState} />
  316. )}
  317. </div>
  318. </div>
  319. </FixedSideContainer>
  320. );
  321. };
  322. const renderSidebars = () => {
  323. return appState.openSidebar === "customSidebar" ? (
  324. renderCustomSidebar?.() || null
  325. ) : appState.openSidebar === "library" ? (
  326. <LibraryMenu
  327. appState={appState}
  328. onInsertElements={onInsertElements}
  329. libraryReturnUrl={libraryReturnUrl}
  330. focusContainer={focusContainer}
  331. library={library}
  332. id={id}
  333. />
  334. ) : null;
  335. };
  336. const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
  337. return (
  338. <>
  339. {/* ------------------------- tunneled UI ---------------------------- */}
  340. {/* make sure we render host app components first so that we can detect
  341. them first on initial render to optimize layout shift */}
  342. {children}
  343. {/* render component fallbacks. Can be rendered anywhere as they'll be
  344. tunneled away. We only render tunneled components that actually
  345. have defaults when host do not render anything. */}
  346. <DefaultMainMenu UIOptions={UIOptions} />
  347. {/* ------------------------------------------------------------------ */}
  348. {appState.isLoading && <LoadingMessage delay={250} />}
  349. {appState.errorMessage && (
  350. <ErrorDialog
  351. message={appState.errorMessage}
  352. onClose={() => setAppState({ errorMessage: null })}
  353. />
  354. )}
  355. {appState.openDialog === "help" && (
  356. <HelpDialog
  357. onClose={() => {
  358. setAppState({ openDialog: null });
  359. }}
  360. />
  361. )}
  362. <ActiveConfirmDialog />
  363. {renderImageExportDialog()}
  364. {renderJSONExportDialog()}
  365. {appState.pasteDialog.shown && (
  366. <PasteChartDialog
  367. setAppState={setAppState}
  368. appState={appState}
  369. onInsertChart={onInsertElements}
  370. onClose={() =>
  371. setAppState({
  372. pasteDialog: { shown: false, data: null },
  373. })
  374. }
  375. />
  376. )}
  377. {device.isMobile && (
  378. <MobileMenu
  379. appState={appState}
  380. elements={elements}
  381. actionManager={actionManager}
  382. renderJSONExportDialog={renderJSONExportDialog}
  383. renderImageExportDialog={renderImageExportDialog}
  384. setAppState={setAppState}
  385. onLockToggle={onLockToggle}
  386. onHandToolToggle={onHandToolToggle}
  387. onPenModeToggle={onPenModeToggle}
  388. canvas={canvas}
  389. onImageAction={onImageAction}
  390. renderTopRightUI={renderTopRightUI}
  391. renderCustomStats={renderCustomStats}
  392. renderSidebars={renderSidebars}
  393. device={device}
  394. />
  395. )}
  396. {!device.isMobile && (
  397. <>
  398. <div
  399. className={clsx("layer-ui__wrapper", {
  400. "disable-pointerEvents":
  401. appState.draggingElement ||
  402. appState.resizingElement ||
  403. (appState.editingElement &&
  404. !isTextElement(appState.editingElement)),
  405. })}
  406. style={
  407. ((appState.openSidebar === "library" &&
  408. appState.isSidebarDocked) ||
  409. hostSidebarCounters.docked) &&
  410. device.canDeviceFitSidebar
  411. ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
  412. : {}
  413. }
  414. >
  415. {renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
  416. {renderFixedSideContainer()}
  417. <Footer
  418. appState={appState}
  419. actionManager={actionManager}
  420. showExitZenModeBtn={showExitZenModeBtn}
  421. renderWelcomeScreen={renderWelcomeScreen}
  422. />
  423. {appState.showStats && (
  424. <Stats
  425. appState={appState}
  426. setAppState={setAppState}
  427. elements={elements}
  428. onClose={() => {
  429. actionManager.executeAction(actionToggleStats);
  430. }}
  431. renderCustomStats={renderCustomStats}
  432. />
  433. )}
  434. {appState.scrolledOutside && (
  435. <button
  436. className="scroll-back-to-content"
  437. onClick={() => {
  438. setAppState({
  439. ...calculateScrollCenter(elements, appState, canvas),
  440. });
  441. }}
  442. >
  443. {t("buttons.scrollBackToContent")}
  444. </button>
  445. )}
  446. </div>
  447. {renderSidebars()}
  448. </>
  449. )}
  450. </>
  451. );
  452. };
  453. const stripIrrelevantAppStateProps = (
  454. appState: AppState,
  455. ): Partial<AppState> => {
  456. const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
  457. appState;
  458. return ret;
  459. };
  460. const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
  461. // short-circuit early
  462. if (prevProps.children !== nextProps.children) {
  463. return false;
  464. }
  465. const {
  466. canvas: _prevCanvas,
  467. // not stable, but shouldn't matter in our case
  468. onInsertElements: _prevOnInsertElements,
  469. appState: prevAppState,
  470. ...prev
  471. } = prevProps;
  472. const {
  473. canvas: _nextCanvas,
  474. onInsertElements: _nextOnInsertElements,
  475. appState: nextAppState,
  476. ...next
  477. } = nextProps;
  478. return (
  479. isShallowEqual(
  480. stripIrrelevantAppStateProps(prevAppState),
  481. stripIrrelevantAppStateProps(nextAppState),
  482. ) && isShallowEqual(prev, next)
  483. );
  484. };
  485. export default React.memo(LayerUI, areEqual);