LayerUI.tsx 18 KB

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