LayerUI.tsx 17 KB

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