LayerUI.tsx 19 KB

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