LibraryMenuItems.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import { chunk } from "lodash";
  2. import React, { useCallback, useState } from "react";
  3. import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
  4. import Library from "../data/library";
  5. import { ExcalidrawElement, NonDeleted } from "../element/types";
  6. import { t } from "../i18n";
  7. import {
  8. AppState,
  9. BinaryFiles,
  10. ExcalidrawProps,
  11. LibraryItem,
  12. LibraryItems,
  13. } from "../types";
  14. import { arrayToMap, muteFSAbortError } from "../utils";
  15. import { useDevice } from "./App";
  16. import ConfirmDialog from "./ConfirmDialog";
  17. import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
  18. import { LibraryUnit } from "./LibraryUnit";
  19. import Stack from "./Stack";
  20. import { ToolButton } from "./ToolButton";
  21. import { Tooltip } from "./Tooltip";
  22. import "./LibraryMenuItems.scss";
  23. import { MIME_TYPES, VERSIONS } from "../constants";
  24. import Spinner from "./Spinner";
  25. import { fileOpen } from "../data/filesystem";
  26. import { SidebarLockButton } from "./SidebarLockButton";
  27. import { trackEvent } from "../analytics";
  28. const LibraryMenuItems = ({
  29. isLoading,
  30. libraryItems,
  31. onRemoveFromLibrary,
  32. onAddToLibrary,
  33. onInsertLibraryItems,
  34. pendingElements,
  35. theme,
  36. setAppState,
  37. appState,
  38. libraryReturnUrl,
  39. library,
  40. files,
  41. id,
  42. selectedItems,
  43. onSelectItems,
  44. onPublish,
  45. resetLibrary,
  46. }: {
  47. isLoading: boolean;
  48. libraryItems: LibraryItems;
  49. pendingElements: LibraryItem["elements"];
  50. onRemoveFromLibrary: () => void;
  51. onInsertLibraryItems: (libraryItems: LibraryItems) => void;
  52. onAddToLibrary: (elements: LibraryItem["elements"]) => void;
  53. theme: AppState["theme"];
  54. files: BinaryFiles;
  55. setAppState: React.Component<any, AppState>["setState"];
  56. appState: AppState;
  57. libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
  58. library: Library;
  59. id: string;
  60. selectedItems: LibraryItem["id"][];
  61. onSelectItems: (id: LibraryItem["id"][]) => void;
  62. onPublish: () => void;
  63. resetLibrary: () => void;
  64. }) => {
  65. const renderRemoveLibAlert = useCallback(() => {
  66. const content = selectedItems.length
  67. ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
  68. : t("alerts.resetLibrary");
  69. const title = selectedItems.length
  70. ? t("confirmDialog.removeItemsFromLib")
  71. : t("confirmDialog.resetLibrary");
  72. return (
  73. <ConfirmDialog
  74. onConfirm={() => {
  75. if (selectedItems.length) {
  76. onRemoveFromLibrary();
  77. } else {
  78. resetLibrary();
  79. }
  80. setShowRemoveLibAlert(false);
  81. }}
  82. onCancel={() => {
  83. setShowRemoveLibAlert(false);
  84. }}
  85. title={title}
  86. >
  87. <p>{content}</p>
  88. </ConfirmDialog>
  89. );
  90. }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
  91. const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
  92. const device = useDevice();
  93. const renderLibraryActions = () => {
  94. const itemsSelected = !!selectedItems.length;
  95. const items = itemsSelected
  96. ? libraryItems.filter((item) => selectedItems.includes(item.id))
  97. : libraryItems;
  98. const resetLabel = itemsSelected
  99. ? t("buttons.remove")
  100. : t("buttons.resetLibrary");
  101. return (
  102. <div className="library-actions">
  103. {!itemsSelected && (
  104. <ToolButton
  105. key="import"
  106. type="button"
  107. title={t("buttons.load")}
  108. aria-label={t("buttons.load")}
  109. icon={load}
  110. onClick={async () => {
  111. try {
  112. await library.updateLibrary({
  113. libraryItems: fileOpen({
  114. description: "Excalidraw library files",
  115. // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
  116. // gets resolved. Else, iOS users cannot open `.excalidraw` files.
  117. /*
  118. extensions: [".json", ".excalidrawlib"],
  119. */
  120. }),
  121. merge: true,
  122. openLibraryMenu: true,
  123. });
  124. } catch (error: any) {
  125. if (error?.name === "AbortError") {
  126. console.warn(error);
  127. return;
  128. }
  129. setAppState({ errorMessage: t("errors.importLibraryError") });
  130. }
  131. }}
  132. className="library-actions--load"
  133. />
  134. )}
  135. {!!items.length && (
  136. <>
  137. <ToolButton
  138. key="export"
  139. type="button"
  140. title={t("buttons.export")}
  141. aria-label={t("buttons.export")}
  142. icon={exportToFileIcon}
  143. onClick={async () => {
  144. const libraryItems = itemsSelected
  145. ? items
  146. : await library.getLatestLibrary();
  147. saveLibraryAsJSON(libraryItems)
  148. .catch(muteFSAbortError)
  149. .catch((error) => {
  150. setAppState({ errorMessage: error.message });
  151. });
  152. }}
  153. className="library-actions--export"
  154. >
  155. {selectedItems.length > 0 && (
  156. <span className="library-actions-counter">
  157. {selectedItems.length}
  158. </span>
  159. )}
  160. </ToolButton>
  161. <ToolButton
  162. key="reset"
  163. type="button"
  164. title={resetLabel}
  165. aria-label={resetLabel}
  166. icon={trash}
  167. onClick={() => setShowRemoveLibAlert(true)}
  168. className="library-actions--remove"
  169. >
  170. {selectedItems.length > 0 && (
  171. <span className="library-actions-counter">
  172. {selectedItems.length}
  173. </span>
  174. )}
  175. </ToolButton>
  176. </>
  177. )}
  178. {itemsSelected && (
  179. <Tooltip label={t("hints.publishLibrary")}>
  180. <ToolButton
  181. type="button"
  182. aria-label={t("buttons.publishLibrary")}
  183. label={t("buttons.publishLibrary")}
  184. icon={publishIcon}
  185. className="library-actions--publish"
  186. onClick={onPublish}
  187. >
  188. {!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
  189. {selectedItems.length > 0 && (
  190. <span className="library-actions-counter">
  191. {selectedItems.length}
  192. </span>
  193. )}
  194. </ToolButton>
  195. </Tooltip>
  196. )}
  197. {device.isMobile && (
  198. <div className="library-menu-browse-button--mobile">
  199. <a
  200. href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
  201. window.name || "_blank"
  202. }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
  203. VERSIONS.excalidrawLibrary
  204. }`}
  205. target="_excalidraw_libraries"
  206. >
  207. {t("labels.libraries")}
  208. </a>
  209. </div>
  210. )}
  211. </div>
  212. );
  213. };
  214. const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
  215. const referrer =
  216. libraryReturnUrl || window.location.origin + window.location.pathname;
  217. const [lastSelectedItem, setLastSelectedItem] = useState<
  218. LibraryItem["id"] | null
  219. >(null);
  220. const onItemSelectToggle = (
  221. id: LibraryItem["id"],
  222. event: React.MouseEvent,
  223. ) => {
  224. const shouldSelect = !selectedItems.includes(id);
  225. const orderedItems = [...unpublishedItems, ...publishedItems];
  226. if (shouldSelect) {
  227. if (event.shiftKey && lastSelectedItem) {
  228. const rangeStart = orderedItems.findIndex(
  229. (item) => item.id === lastSelectedItem,
  230. );
  231. const rangeEnd = orderedItems.findIndex((item) => item.id === id);
  232. if (rangeStart === -1 || rangeEnd === -1) {
  233. onSelectItems([...selectedItems, id]);
  234. return;
  235. }
  236. const selectedItemsMap = arrayToMap(selectedItems);
  237. const nextSelectedIds = orderedItems.reduce(
  238. (acc: LibraryItem["id"][], item, idx) => {
  239. if (
  240. (idx >= rangeStart && idx <= rangeEnd) ||
  241. selectedItemsMap.has(item.id)
  242. ) {
  243. acc.push(item.id);
  244. }
  245. return acc;
  246. },
  247. [],
  248. );
  249. onSelectItems(nextSelectedIds);
  250. } else {
  251. onSelectItems([...selectedItems, id]);
  252. }
  253. setLastSelectedItem(id);
  254. } else {
  255. setLastSelectedItem(null);
  256. onSelectItems(selectedItems.filter((_id) => _id !== id));
  257. }
  258. };
  259. const getInsertedElements = (id: string) => {
  260. let targetElements;
  261. if (selectedItems.includes(id)) {
  262. targetElements = libraryItems.filter((item) =>
  263. selectedItems.includes(item.id),
  264. );
  265. } else {
  266. targetElements = libraryItems.filter((item) => item.id === id);
  267. }
  268. return targetElements;
  269. };
  270. const createLibraryItemCompo = (params: {
  271. item:
  272. | LibraryItem
  273. | /* pending library item */ {
  274. id: null;
  275. elements: readonly NonDeleted<ExcalidrawElement>[];
  276. }
  277. | null;
  278. onClick?: () => void;
  279. key: string;
  280. }) => {
  281. return (
  282. <Stack.Col key={params.key}>
  283. <LibraryUnit
  284. elements={params.item?.elements}
  285. files={files}
  286. isPending={!params.item?.id && !!params.item?.elements}
  287. onClick={params.onClick || (() => {})}
  288. id={params.item?.id || null}
  289. selected={!!params.item?.id && selectedItems.includes(params.item.id)}
  290. onToggle={onItemSelectToggle}
  291. onDrag={(id, event) => {
  292. event.dataTransfer.setData(
  293. MIME_TYPES.excalidrawlib,
  294. serializeLibraryAsJSON(getInsertedElements(id)),
  295. );
  296. }}
  297. />
  298. </Stack.Col>
  299. );
  300. };
  301. const renderLibrarySection = (
  302. items: (
  303. | LibraryItem
  304. | /* pending library item */ {
  305. id: null;
  306. elements: readonly NonDeleted<ExcalidrawElement>[];
  307. }
  308. )[],
  309. ) => {
  310. const _items = items.map((item) => {
  311. if (item.id) {
  312. return createLibraryItemCompo({
  313. item,
  314. onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
  315. key: item.id,
  316. });
  317. }
  318. return createLibraryItemCompo({
  319. key: "__pending__item__",
  320. item,
  321. onClick: () => onAddToLibrary(pendingElements),
  322. });
  323. });
  324. // ensure we render all empty cells if no items are present
  325. let rows = chunk(_items, CELLS_PER_ROW);
  326. if (!rows.length) {
  327. rows = [[]];
  328. }
  329. return rows.map((rowItems, index, rows) => {
  330. if (index === rows.length - 1) {
  331. // pad row with empty cells
  332. rowItems = rowItems.concat(
  333. new Array(CELLS_PER_ROW - rowItems.length)
  334. .fill(null)
  335. .map((_, index) => {
  336. return createLibraryItemCompo({
  337. key: `empty_${index}`,
  338. item: null,
  339. });
  340. }),
  341. );
  342. }
  343. return (
  344. <Stack.Row align="center" gap={1} key={index}>
  345. {rowItems}
  346. </Stack.Row>
  347. );
  348. });
  349. };
  350. const unpublishedItems = libraryItems.filter(
  351. (item) => item.status !== "published",
  352. );
  353. const publishedItems = libraryItems.filter(
  354. (item) => item.status === "published",
  355. );
  356. const renderLibraryHeader = () => {
  357. return (
  358. <>
  359. <div className="layer-ui__library-header" key="library-header">
  360. {renderLibraryActions()}
  361. {device.canDeviceFitSidebar && (
  362. <>
  363. <div className="layer-ui__sidebar-lock-button">
  364. <SidebarLockButton
  365. checked={appState.isLibraryMenuDocked}
  366. onChange={() => {
  367. document
  368. .querySelector(".layer-ui__wrapper")
  369. ?.classList.add("animate");
  370. const nextState = !appState.isLibraryMenuDocked;
  371. setAppState({
  372. isLibraryMenuDocked: nextState,
  373. });
  374. trackEvent(
  375. "library",
  376. `toggleLibraryDock (${nextState ? "dock" : "undock"})`,
  377. `sidebar (${device.isMobile ? "mobile" : "desktop"})`,
  378. );
  379. }}
  380. />
  381. </div>
  382. </>
  383. )}
  384. {!device.isMobile && (
  385. <div className="ToolIcon__icon__close">
  386. <button
  387. className="Modal__close"
  388. onClick={() =>
  389. setAppState({
  390. isLibraryOpen: false,
  391. })
  392. }
  393. aria-label={t("buttons.close")}
  394. >
  395. {close}
  396. </button>
  397. </div>
  398. )}
  399. </div>
  400. </>
  401. );
  402. };
  403. const renderLibraryMenuItems = () => {
  404. return (
  405. <Stack.Col
  406. className="library-menu-items-container__items"
  407. align="start"
  408. gap={1}
  409. style={{
  410. flex: publishedItems.length > 0 ? 1 : "0 1 auto",
  411. marginBottom: 0,
  412. }}
  413. >
  414. <>
  415. <div className="separator">
  416. {(pendingElements.length > 0 ||
  417. unpublishedItems.length > 0 ||
  418. publishedItems.length > 0) && (
  419. <div>{t("labels.personalLib")}</div>
  420. )}
  421. {isLoading && (
  422. <div
  423. style={{
  424. marginLeft: "auto",
  425. marginRight: "1rem",
  426. display: "flex",
  427. alignItems: "center",
  428. fontWeight: "normal",
  429. }}
  430. >
  431. <div style={{ transform: "translateY(2px)" }}>
  432. <Spinner />
  433. </div>
  434. </div>
  435. )}
  436. </div>
  437. {!pendingElements.length && !unpublishedItems.length ? (
  438. <div
  439. style={{
  440. height: 65,
  441. display: "flex",
  442. flexDirection: "column",
  443. alignItems: "center",
  444. justifyContent: "center",
  445. width: "100%",
  446. fontSize: ".9rem",
  447. }}
  448. >
  449. {t("library.noItems")}
  450. <div
  451. style={{
  452. margin: ".6rem 0",
  453. fontSize: ".8em",
  454. width: "70%",
  455. textAlign: "center",
  456. }}
  457. >
  458. {publishedItems.length > 0
  459. ? t("library.hint_emptyPrivateLibrary")
  460. : t("library.hint_emptyLibrary")}
  461. </div>
  462. </div>
  463. ) : (
  464. renderLibrarySection([
  465. // append pending library item
  466. ...(pendingElements.length
  467. ? [{ id: null, elements: pendingElements }]
  468. : []),
  469. ...unpublishedItems,
  470. ])
  471. )}
  472. </>
  473. <>
  474. {(publishedItems.length > 0 ||
  475. (!device.isMobile &&
  476. (pendingElements.length > 0 || unpublishedItems.length > 0))) && (
  477. <div className="separator">{t("labels.excalidrawLib")}</div>
  478. )}
  479. {publishedItems.length > 0 ? (
  480. renderLibrarySection(publishedItems)
  481. ) : unpublishedItems.length > 0 ? (
  482. <div
  483. style={{
  484. margin: "1rem 0",
  485. display: "flex",
  486. flexDirection: "column",
  487. alignItems: "center",
  488. justifyContent: "center",
  489. width: "100%",
  490. fontSize: ".9rem",
  491. }}
  492. >
  493. {t("library.noItems")}
  494. </div>
  495. ) : null}
  496. </>
  497. </Stack.Col>
  498. );
  499. };
  500. const renderLibraryFooter = () => {
  501. return (
  502. <a
  503. className="library-menu-browse-button"
  504. href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
  505. window.name || "_blank"
  506. }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
  507. VERSIONS.excalidrawLibrary
  508. }`}
  509. target="_excalidraw_libraries"
  510. >
  511. {t("labels.libraries")}
  512. </a>
  513. );
  514. };
  515. return (
  516. <div
  517. className="library-menu-items-container"
  518. style={
  519. device.isMobile
  520. ? {
  521. minHeight: "200px",
  522. maxHeight: "70vh",
  523. }
  524. : undefined
  525. }
  526. >
  527. {showRemoveLibAlert && renderRemoveLibAlert()}
  528. {renderLibraryHeader()}
  529. {renderLibraryMenuItems()}
  530. {!device.isMobile && renderLibraryFooter()}
  531. </div>
  532. );
  533. };
  534. export default LibraryMenuItems;