LayerUI.tsx 19 KB

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