LayerUI.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. import clsx from "clsx";
  2. import React, {
  3. RefObject,
  4. useCallback,
  5. useEffect,
  6. useRef,
  7. useState,
  8. } from "react";
  9. import { ActionManager } from "../actions/manager";
  10. import { CLASSES } from "../constants";
  11. import { exportCanvas } from "../data";
  12. import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
  13. import { Library } from "../data/library";
  14. import { showSelectedShapeActions } from "../element";
  15. import { NonDeletedExcalidrawElement } from "../element/types";
  16. import { Language, t } from "../i18n";
  17. import useIsMobile from "../is-mobile";
  18. import { calculateScrollCenter, getSelectedElements } from "../scene";
  19. import { ExportType } from "../scene/types";
  20. import { AppState, LibraryItem, LibraryItems } from "../types";
  21. import { muteFSAbortError } from "../utils";
  22. import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
  23. import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
  24. import CollabButton from "./CollabButton";
  25. import { ErrorDialog } from "./ErrorDialog";
  26. import { ExportCB, ExportDialog } from "./ExportDialog";
  27. import { FixedSideContainer } from "./FixedSideContainer";
  28. import { GitHubCorner } from "./GitHubCorner";
  29. import { HintViewer } from "./HintViewer";
  30. import { exportFile, load, shield } from "./icons";
  31. import { Island } from "./Island";
  32. import "./LayerUI.scss";
  33. import { LibraryUnit } from "./LibraryUnit";
  34. import { LoadingMessage } from "./LoadingMessage";
  35. import { LockIcon } from "./LockIcon";
  36. import { MobileMenu } from "./MobileMenu";
  37. import { PasteChartDialog } from "./PasteChartDialog";
  38. import { Section } from "./Section";
  39. import { ShortcutsDialog } from "./ShortcutsDialog";
  40. import Stack from "./Stack";
  41. import { ToolButton } from "./ToolButton";
  42. import { Tooltip } from "./Tooltip";
  43. import { UserList } from "./UserList";
  44. interface LayerUIProps {
  45. actionManager: ActionManager;
  46. appState: AppState;
  47. canvas: HTMLCanvasElement | null;
  48. setAppState: React.Component<any, AppState>["setState"];
  49. elements: readonly NonDeletedExcalidrawElement[];
  50. onCollabButtonClick?: () => void;
  51. onLockToggle: () => void;
  52. onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
  53. zenModeEnabled: boolean;
  54. toggleZenMode: () => void;
  55. langCode: Language["code"];
  56. isCollaborating: boolean;
  57. onExportToBackend?: (
  58. exportedElements: readonly NonDeletedExcalidrawElement[],
  59. appState: AppState,
  60. canvas: HTMLCanvasElement | null,
  61. ) => void;
  62. renderCustomFooter?: (isMobile: boolean) => JSX.Element;
  63. }
  64. const useOnClickOutside = (
  65. ref: RefObject<HTMLElement>,
  66. cb: (event: MouseEvent) => void,
  67. ) => {
  68. useEffect(() => {
  69. const listener = (event: MouseEvent) => {
  70. if (!ref.current) {
  71. return;
  72. }
  73. if (
  74. event.target instanceof Element &&
  75. (ref.current.contains(event.target) ||
  76. !document.body.contains(event.target))
  77. ) {
  78. return;
  79. }
  80. cb(event);
  81. };
  82. document.addEventListener("pointerdown", listener, false);
  83. return () => {
  84. document.removeEventListener("pointerdown", listener);
  85. };
  86. }, [ref, cb]);
  87. };
  88. const LibraryMenuItems = ({
  89. library,
  90. onRemoveFromLibrary,
  91. onAddToLibrary,
  92. onInsertShape,
  93. pendingElements,
  94. setAppState,
  95. }: {
  96. library: LibraryItems;
  97. pendingElements: LibraryItem;
  98. onRemoveFromLibrary: (index: number) => void;
  99. onInsertShape: (elements: LibraryItem) => void;
  100. onAddToLibrary: (elements: LibraryItem) => void;
  101. setAppState: React.Component<any, AppState>["setState"];
  102. }) => {
  103. const isMobile = useIsMobile();
  104. const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
  105. const CELLS_PER_ROW = isMobile ? 4 : 6;
  106. const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
  107. const rows = [];
  108. let addedPendingElements = false;
  109. rows.push(
  110. <div className="layer-ui__library-header">
  111. <ToolButton
  112. key="import"
  113. type="button"
  114. title={t("buttons.load")}
  115. aria-label={t("buttons.load")}
  116. icon={load}
  117. onClick={() => {
  118. importLibraryFromJSON()
  119. .then(() => {
  120. // Maybe we should close and open the menu so that the items get updated.
  121. // But for now we just close the menu.
  122. setAppState({ isLibraryOpen: false });
  123. })
  124. .catch(muteFSAbortError)
  125. .catch((error) => {
  126. setAppState({ errorMessage: error.message });
  127. });
  128. }}
  129. />
  130. <ToolButton
  131. key="export"
  132. type="button"
  133. title={t("buttons.export")}
  134. aria-label={t("buttons.export")}
  135. icon={exportFile}
  136. onClick={() => {
  137. saveLibraryAsJSON()
  138. .catch(muteFSAbortError)
  139. .catch((error) => {
  140. setAppState({ errorMessage: error.message });
  141. });
  142. }}
  143. />
  144. <a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
  145. {t("labels.libraries")}
  146. </a>
  147. </div>,
  148. );
  149. for (let row = 0; row < numRows; row++) {
  150. const y = CELLS_PER_ROW * row;
  151. const children = [];
  152. for (let x = 0; x < CELLS_PER_ROW; x++) {
  153. const shouldAddPendingElements: boolean =
  154. pendingElements.length > 0 &&
  155. !addedPendingElements &&
  156. y + x >= library.length;
  157. addedPendingElements = addedPendingElements || shouldAddPendingElements;
  158. children.push(
  159. <Stack.Col key={x}>
  160. <LibraryUnit
  161. elements={library[y + x]}
  162. pendingElements={
  163. shouldAddPendingElements ? pendingElements : undefined
  164. }
  165. onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
  166. onClick={
  167. shouldAddPendingElements
  168. ? onAddToLibrary.bind(null, pendingElements)
  169. : onInsertShape.bind(null, library[y + x])
  170. }
  171. />
  172. </Stack.Col>,
  173. );
  174. }
  175. rows.push(
  176. <Stack.Row align="center" gap={1} key={row}>
  177. {children}
  178. </Stack.Row>,
  179. );
  180. }
  181. return (
  182. <Stack.Col align="start" gap={1} className="layer-ui__library-items">
  183. {rows}
  184. </Stack.Col>
  185. );
  186. };
  187. const LibraryMenu = ({
  188. onClickOutside,
  189. onInsertShape,
  190. pendingElements,
  191. onAddToLibrary,
  192. setAppState,
  193. }: {
  194. pendingElements: LibraryItem;
  195. onClickOutside: (event: MouseEvent) => void;
  196. onInsertShape: (elements: LibraryItem) => void;
  197. onAddToLibrary: () => void;
  198. setAppState: React.Component<any, AppState>["setState"];
  199. }) => {
  200. const ref = useRef<HTMLDivElement | null>(null);
  201. useOnClickOutside(ref, (event) => {
  202. // If click on the library icon, do nothing.
  203. if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
  204. return;
  205. }
  206. onClickOutside(event);
  207. });
  208. const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
  209. const [loadingState, setIsLoading] = useState<
  210. "preloading" | "loading" | "ready"
  211. >("preloading");
  212. const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
  213. useEffect(() => {
  214. Promise.race([
  215. new Promise((resolve) => {
  216. loadingTimerRef.current = setTimeout(() => {
  217. resolve("loading");
  218. }, 100);
  219. }),
  220. Library.loadLibrary().then((items) => {
  221. setLibraryItems(items);
  222. setIsLoading("ready");
  223. }),
  224. ]).then((data) => {
  225. if (data === "loading") {
  226. setIsLoading("loading");
  227. }
  228. });
  229. return () => {
  230. clearTimeout(loadingTimerRef.current!);
  231. };
  232. }, []);
  233. const removeFromLibrary = useCallback(async (indexToRemove) => {
  234. const items = await Library.loadLibrary();
  235. const nextItems = items.filter((_, index) => index !== indexToRemove);
  236. Library.saveLibrary(nextItems);
  237. setLibraryItems(nextItems);
  238. }, []);
  239. const addToLibrary = useCallback(
  240. async (elements: LibraryItem) => {
  241. const items = await Library.loadLibrary();
  242. const nextItems = [...items, elements];
  243. onAddToLibrary();
  244. Library.saveLibrary(nextItems);
  245. setLibraryItems(nextItems);
  246. },
  247. [onAddToLibrary],
  248. );
  249. return loadingState === "preloading" ? null : (
  250. <Island padding={1} ref={ref} className="layer-ui__library">
  251. {loadingState === "loading" ? (
  252. <div className="layer-ui__library-message">
  253. {t("labels.libraryLoadingMessage")}
  254. </div>
  255. ) : (
  256. <LibraryMenuItems
  257. library={libraryItems}
  258. onRemoveFromLibrary={removeFromLibrary}
  259. onAddToLibrary={addToLibrary}
  260. onInsertShape={onInsertShape}
  261. pendingElements={pendingElements}
  262. setAppState={setAppState}
  263. />
  264. )}
  265. </Island>
  266. );
  267. };
  268. const LayerUI = ({
  269. actionManager,
  270. appState,
  271. setAppState,
  272. canvas,
  273. elements,
  274. onCollabButtonClick,
  275. onLockToggle,
  276. onInsertElements,
  277. zenModeEnabled,
  278. toggleZenMode,
  279. isCollaborating,
  280. onExportToBackend,
  281. renderCustomFooter,
  282. }: LayerUIProps) => {
  283. const isMobile = useIsMobile();
  284. const renderEncryptedIcon = () => (
  285. <a
  286. className={clsx("encrypted-icon tooltip zen-mode-visibility", {
  287. "zen-mode-visibility--hidden": zenModeEnabled,
  288. })}
  289. href="https://blog.excalidraw.com/end-to-end-encryption/"
  290. target="_blank"
  291. rel="noopener noreferrer"
  292. >
  293. <Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
  294. {shield}
  295. </Tooltip>
  296. </a>
  297. );
  298. const renderExportDialog = () => {
  299. const createExporter = (type: ExportType): ExportCB => async (
  300. exportedElements,
  301. scale,
  302. ) => {
  303. if (canvas) {
  304. await exportCanvas(type, exportedElements, appState, canvas, {
  305. exportBackground: appState.exportBackground,
  306. name: appState.name,
  307. viewBackgroundColor: appState.viewBackgroundColor,
  308. scale,
  309. shouldAddWatermark: appState.shouldAddWatermark,
  310. })
  311. .catch(muteFSAbortError)
  312. .catch((error) => {
  313. console.error(error);
  314. setAppState({ errorMessage: error.message });
  315. });
  316. }
  317. };
  318. return (
  319. <ExportDialog
  320. elements={elements}
  321. appState={appState}
  322. actionManager={actionManager}
  323. onExportToPng={createExporter("png")}
  324. onExportToSvg={createExporter("svg")}
  325. onExportToClipboard={createExporter("clipboard")}
  326. onExportToBackend={
  327. onExportToBackend
  328. ? (elements) => {
  329. onExportToBackend &&
  330. onExportToBackend(elements, appState, canvas);
  331. }
  332. : undefined
  333. }
  334. />
  335. );
  336. };
  337. const renderCanvasActions = () => (
  338. <Section
  339. heading="canvasActions"
  340. className={clsx("zen-mode-transition", {
  341. "transition-left": zenModeEnabled,
  342. })}
  343. >
  344. {/* the zIndex ensures this menu has higher stacking order,
  345. see https://github.com/excalidraw/excalidraw/pull/1445 */}
  346. <Island padding={2} style={{ zIndex: 1 }}>
  347. <Stack.Col gap={4}>
  348. <Stack.Row gap={1} justifyContent="space-between">
  349. {actionManager.renderAction("loadScene")}
  350. {actionManager.renderAction("saveScene")}
  351. {actionManager.renderAction("saveAsScene")}
  352. {renderExportDialog()}
  353. {actionManager.renderAction("clearCanvas")}
  354. {onCollabButtonClick && (
  355. <CollabButton
  356. isCollaborating={isCollaborating}
  357. collaboratorCount={appState.collaborators.size}
  358. onClick={onCollabButtonClick}
  359. />
  360. )}
  361. </Stack.Row>
  362. <BackgroundPickerAndDarkModeToggle
  363. actionManager={actionManager}
  364. appState={appState}
  365. setAppState={setAppState}
  366. />
  367. </Stack.Col>
  368. </Island>
  369. </Section>
  370. );
  371. const renderSelectedShapeActions = () => (
  372. <Section
  373. heading="selectedShapeActions"
  374. className={clsx("zen-mode-transition", {
  375. "transition-left": zenModeEnabled,
  376. })}
  377. >
  378. <Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={2}>
  379. <SelectedShapeActions
  380. appState={appState}
  381. elements={elements}
  382. renderAction={actionManager.renderAction}
  383. elementType={appState.elementType}
  384. />
  385. </Island>
  386. </Section>
  387. );
  388. const closeLibrary = useCallback(
  389. (event) => {
  390. setAppState({ isLibraryOpen: false });
  391. },
  392. [setAppState],
  393. );
  394. const deselectItems = useCallback(() => {
  395. setAppState({
  396. selectedElementIds: {},
  397. selectedGroupIds: {},
  398. });
  399. }, [setAppState]);
  400. const libraryMenu = appState.isLibraryOpen ? (
  401. <LibraryMenu
  402. pendingElements={getSelectedElements(elements, appState)}
  403. onClickOutside={closeLibrary}
  404. onInsertShape={onInsertElements}
  405. onAddToLibrary={deselectItems}
  406. setAppState={setAppState}
  407. />
  408. ) : null;
  409. const renderFixedSideContainer = () => {
  410. const shouldRenderSelectedShapeActions = showSelectedShapeActions(
  411. appState,
  412. elements,
  413. );
  414. return (
  415. <FixedSideContainer side="top">
  416. <div className="App-menu App-menu_top">
  417. <Stack.Col
  418. gap={4}
  419. className={clsx({ "disable-pointerEvents": zenModeEnabled })}
  420. >
  421. {renderCanvasActions()}
  422. {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
  423. </Stack.Col>
  424. <Section heading="shapes">
  425. {(heading) => (
  426. <Stack.Col gap={4} align="start">
  427. <Stack.Row gap={1}>
  428. <Island
  429. padding={1}
  430. className={clsx({ "zen-mode": zenModeEnabled })}
  431. >
  432. <HintViewer appState={appState} elements={elements} />
  433. {heading}
  434. <Stack.Row gap={1}>
  435. <ShapesSwitcher
  436. elementType={appState.elementType}
  437. setAppState={setAppState}
  438. isLibraryOpen={appState.isLibraryOpen}
  439. />
  440. </Stack.Row>
  441. </Island>
  442. <LockIcon
  443. zenModeEnabled={zenModeEnabled}
  444. checked={appState.elementLocked}
  445. onChange={onLockToggle}
  446. title={t("toolBar.lock")}
  447. />
  448. </Stack.Row>
  449. {libraryMenu}
  450. </Stack.Col>
  451. )}
  452. </Section>
  453. <UserList
  454. className={clsx("zen-mode-transition", {
  455. "transition-right": zenModeEnabled,
  456. })}
  457. >
  458. {Array.from(appState.collaborators)
  459. // Collaborator is either not initialized or is actually the current user.
  460. .filter(([_, client]) => Object.keys(client).length !== 0)
  461. .map(([clientId, client]) => (
  462. <Tooltip
  463. label={client.username || "Unknown user"}
  464. key={clientId}
  465. >
  466. {actionManager.renderAction("goToCollaborator", clientId)}
  467. </Tooltip>
  468. ))}
  469. </UserList>
  470. </div>
  471. </FixedSideContainer>
  472. );
  473. };
  474. const renderBottomAppMenu = () => {
  475. return (
  476. <div
  477. className={clsx("App-menu App-menu_bottom zen-mode-transition", {
  478. "App-menu_bottom--transition-left": zenModeEnabled,
  479. })}
  480. >
  481. <Stack.Col gap={2}>
  482. <Section heading="canvasActions">
  483. <Island padding={1}>
  484. <ZoomActions
  485. renderAction={actionManager.renderAction}
  486. zoom={appState.zoom}
  487. />
  488. </Island>
  489. {renderEncryptedIcon()}
  490. </Section>
  491. </Stack.Col>
  492. </div>
  493. );
  494. };
  495. const renderFooter = () => (
  496. <footer role="contentinfo" className="layer-ui__wrapper__footer">
  497. <div
  498. className={clsx("zen-mode-transition", {
  499. "transition-right disable-pointerEvents": zenModeEnabled,
  500. })}
  501. >
  502. {renderCustomFooter?.(false)}
  503. {actionManager.renderAction("toggleShortcuts")}
  504. </div>
  505. <button
  506. className={clsx("disable-zen-mode", {
  507. "disable-zen-mode--visible": zenModeEnabled,
  508. })}
  509. onClick={toggleZenMode}
  510. >
  511. {t("buttons.exitZenMode")}
  512. </button>
  513. {appState.scrolledOutside && (
  514. <button
  515. className="scroll-back-to-content"
  516. onClick={() => {
  517. setAppState({
  518. ...calculateScrollCenter(elements, appState, canvas),
  519. });
  520. }}
  521. >
  522. {t("buttons.scrollBackToContent")}
  523. </button>
  524. )}
  525. </footer>
  526. );
  527. const dialogs = (
  528. <>
  529. {appState.isLoading && <LoadingMessage />}
  530. {appState.errorMessage && (
  531. <ErrorDialog
  532. message={appState.errorMessage}
  533. onClose={() => setAppState({ errorMessage: null })}
  534. />
  535. )}
  536. {appState.showShortcutsDialog && (
  537. <ShortcutsDialog
  538. onClose={() => setAppState({ showShortcutsDialog: false })}
  539. />
  540. )}
  541. {appState.pasteDialog.shown && (
  542. <PasteChartDialog
  543. setAppState={setAppState}
  544. appState={appState}
  545. onInsertChart={onInsertElements}
  546. onClose={() =>
  547. setAppState({
  548. pasteDialog: { shown: false, data: null },
  549. })
  550. }
  551. />
  552. )}
  553. </>
  554. );
  555. return isMobile ? (
  556. <>
  557. {dialogs}
  558. <MobileMenu
  559. appState={appState}
  560. elements={elements}
  561. actionManager={actionManager}
  562. libraryMenu={libraryMenu}
  563. exportButton={renderExportDialog()}
  564. setAppState={setAppState}
  565. onCollabButtonClick={onCollabButtonClick}
  566. onLockToggle={onLockToggle}
  567. canvas={canvas}
  568. isCollaborating={isCollaborating}
  569. renderCustomFooter={renderCustomFooter}
  570. />
  571. </>
  572. ) : (
  573. <div className="layer-ui__wrapper">
  574. {dialogs}
  575. {renderFixedSideContainer()}
  576. {renderBottomAppMenu()}
  577. {
  578. <aside
  579. className={clsx(
  580. "layer-ui__wrapper__github-corner zen-mode-transition",
  581. {
  582. "transition-right": zenModeEnabled,
  583. },
  584. )}
  585. >
  586. <GitHubCorner appearance={appState.appearance} />
  587. </aside>
  588. }
  589. {renderFooter()}
  590. </div>
  591. );
  592. };
  593. const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
  594. const getNecessaryObj = (appState: AppState): Partial<AppState> => {
  595. const {
  596. suggestedBindings,
  597. startBoundElement: boundElement,
  598. ...ret
  599. } = appState;
  600. return ret;
  601. };
  602. const prevAppState = getNecessaryObj(prev.appState);
  603. const nextAppState = getNecessaryObj(next.appState);
  604. const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
  605. return (
  606. prev.langCode === next.langCode &&
  607. prev.elements === next.elements &&
  608. keys.every((key) => prevAppState[key] === nextAppState[key])
  609. );
  610. };
  611. export default React.memo(LayerUI, areEqual);