LibraryMenuItems.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import { chunk } from "lodash";
  2. import React, { useCallback, useState } from "react";
  3. import { saveLibraryAsJSON } 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 { useDeviceType } from "./App";
  16. import ConfirmDialog from "./ConfirmDialog";
  17. import { 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 { VERSIONS } from "../constants";
  24. import Spinner from "./Spinner";
  25. import { fileOpen } from "../data/filesystem";
  26. const LibraryMenuItems = ({
  27. isLoading,
  28. libraryItems,
  29. onRemoveFromLibrary,
  30. onAddToLibrary,
  31. onInsertShape,
  32. pendingElements,
  33. theme,
  34. setAppState,
  35. libraryReturnUrl,
  36. library,
  37. files,
  38. id,
  39. selectedItems,
  40. onSelectItems,
  41. onPublish,
  42. resetLibrary,
  43. }: {
  44. isLoading: boolean;
  45. libraryItems: LibraryItems;
  46. pendingElements: LibraryItem["elements"];
  47. onRemoveFromLibrary: () => void;
  48. onInsertShape: (elements: LibraryItem["elements"]) => void;
  49. onAddToLibrary: (elements: LibraryItem["elements"]) => void;
  50. theme: AppState["theme"];
  51. files: BinaryFiles;
  52. setAppState: React.Component<any, AppState>["setState"];
  53. libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
  54. library: Library;
  55. id: string;
  56. selectedItems: LibraryItem["id"][];
  57. onSelectItems: (id: LibraryItem["id"][]) => void;
  58. onPublish: () => void;
  59. resetLibrary: () => void;
  60. }) => {
  61. const renderRemoveLibAlert = useCallback(() => {
  62. const content = selectedItems.length
  63. ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
  64. : t("alerts.resetLibrary");
  65. const title = selectedItems.length
  66. ? t("confirmDialog.removeItemsFromLib")
  67. : t("confirmDialog.resetLibrary");
  68. return (
  69. <ConfirmDialog
  70. onConfirm={() => {
  71. if (selectedItems.length) {
  72. onRemoveFromLibrary();
  73. } else {
  74. resetLibrary();
  75. }
  76. setShowRemoveLibAlert(false);
  77. }}
  78. onCancel={() => {
  79. setShowRemoveLibAlert(false);
  80. }}
  81. title={title}
  82. >
  83. <p>{content}</p>
  84. </ConfirmDialog>
  85. );
  86. }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
  87. const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
  88. const isMobile = useDeviceType().isMobile;
  89. const renderLibraryActions = () => {
  90. const itemsSelected = !!selectedItems.length;
  91. const items = itemsSelected
  92. ? libraryItems.filter((item) => selectedItems.includes(item.id))
  93. : libraryItems;
  94. const resetLabel = itemsSelected
  95. ? t("buttons.remove")
  96. : t("buttons.resetLibrary");
  97. return (
  98. <div className="library-actions">
  99. {(!itemsSelected || !isMobile) && (
  100. <ToolButton
  101. key="import"
  102. type="button"
  103. title={t("buttons.load")}
  104. aria-label={t("buttons.load")}
  105. icon={load}
  106. onClick={async () => {
  107. try {
  108. await fileOpen({
  109. description: "Excalidraw library files",
  110. // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
  111. // gets resolved. Else, iOS users cannot open `.excalidraw` files.
  112. /*
  113. extensions: [".json", ".excalidrawlib"],
  114. */
  115. });
  116. } catch (error: any) {
  117. if (error?.name === "AbortError") {
  118. console.warn(error);
  119. return;
  120. }
  121. setAppState({ errorMessage: t("errors.importLibraryError") });
  122. }
  123. }}
  124. className="library-actions--load"
  125. />
  126. )}
  127. {!!items.length && (
  128. <>
  129. <ToolButton
  130. key="export"
  131. type="button"
  132. title={t("buttons.export")}
  133. aria-label={t("buttons.export")}
  134. icon={exportToFileIcon}
  135. onClick={async () => {
  136. const libraryItems = itemsSelected
  137. ? items
  138. : await library.getLatestLibrary();
  139. saveLibraryAsJSON(libraryItems)
  140. .catch(muteFSAbortError)
  141. .catch((error) => {
  142. setAppState({ errorMessage: error.message });
  143. });
  144. }}
  145. className="library-actions--export"
  146. >
  147. {selectedItems.length > 0 && (
  148. <span className="library-actions-counter">
  149. {selectedItems.length}
  150. </span>
  151. )}
  152. </ToolButton>
  153. <ToolButton
  154. key="reset"
  155. type="button"
  156. title={resetLabel}
  157. aria-label={resetLabel}
  158. icon={trash}
  159. onClick={() => setShowRemoveLibAlert(true)}
  160. className="library-actions--remove"
  161. >
  162. {selectedItems.length > 0 && (
  163. <span className="library-actions-counter">
  164. {selectedItems.length}
  165. </span>
  166. )}
  167. </ToolButton>
  168. </>
  169. )}
  170. {itemsSelected && !isPublished && (
  171. <Tooltip label={t("hints.publishLibrary")}>
  172. <ToolButton
  173. type="button"
  174. aria-label={t("buttons.publishLibrary")}
  175. label={t("buttons.publishLibrary")}
  176. icon={publishIcon}
  177. className="library-actions--publish"
  178. onClick={onPublish}
  179. >
  180. {!isMobile && <label>{t("buttons.publishLibrary")}</label>}
  181. {selectedItems.length > 0 && (
  182. <span className="library-actions-counter">
  183. {selectedItems.length}
  184. </span>
  185. )}
  186. </ToolButton>
  187. </Tooltip>
  188. )}
  189. </div>
  190. );
  191. };
  192. const CELLS_PER_ROW = isMobile ? 4 : 6;
  193. const referrer =
  194. libraryReturnUrl || window.location.origin + window.location.pathname;
  195. const isPublished = selectedItems.some(
  196. (id) => libraryItems.find((item) => item.id === id)?.status === "published",
  197. );
  198. const [lastSelectedItem, setLastSelectedItem] = useState<
  199. LibraryItem["id"] | null
  200. >(null);
  201. const onItemSelectToggle = (
  202. id: LibraryItem["id"],
  203. event: React.MouseEvent,
  204. ) => {
  205. const shouldSelect = !selectedItems.includes(id);
  206. const orderedItems = [...unpublishedItems, ...publishedItems];
  207. if (shouldSelect) {
  208. if (event.shiftKey && lastSelectedItem) {
  209. const rangeStart = orderedItems.findIndex(
  210. (item) => item.id === lastSelectedItem,
  211. );
  212. const rangeEnd = orderedItems.findIndex((item) => item.id === id);
  213. if (rangeStart === -1 || rangeEnd === -1) {
  214. onSelectItems([...selectedItems, id]);
  215. return;
  216. }
  217. const selectedItemsMap = arrayToMap(selectedItems);
  218. const nextSelectedIds = orderedItems.reduce(
  219. (acc: LibraryItem["id"][], item, idx) => {
  220. if (
  221. (idx >= rangeStart && idx <= rangeEnd) ||
  222. selectedItemsMap.has(item.id)
  223. ) {
  224. acc.push(item.id);
  225. }
  226. return acc;
  227. },
  228. [],
  229. );
  230. onSelectItems(nextSelectedIds);
  231. } else {
  232. onSelectItems([...selectedItems, id]);
  233. }
  234. setLastSelectedItem(id);
  235. } else {
  236. setLastSelectedItem(null);
  237. onSelectItems(selectedItems.filter((_id) => _id !== id));
  238. }
  239. };
  240. const createLibraryItemCompo = (params: {
  241. item:
  242. | LibraryItem
  243. | /* pending library item */ {
  244. id: null;
  245. elements: readonly NonDeleted<ExcalidrawElement>[];
  246. }
  247. | null;
  248. onClick?: () => void;
  249. key: string;
  250. }) => {
  251. return (
  252. <Stack.Col key={params.key}>
  253. <LibraryUnit
  254. elements={params.item?.elements}
  255. files={files}
  256. isPending={!params.item?.id && !!params.item?.elements}
  257. onClick={params.onClick || (() => {})}
  258. id={params.item?.id || null}
  259. selected={!!params.item?.id && selectedItems.includes(params.item.id)}
  260. onToggle={onItemSelectToggle}
  261. />
  262. </Stack.Col>
  263. );
  264. };
  265. const renderLibrarySection = (
  266. items: (
  267. | LibraryItem
  268. | /* pending library item */ {
  269. id: null;
  270. elements: readonly NonDeleted<ExcalidrawElement>[];
  271. }
  272. )[],
  273. ) => {
  274. const _items = items.map((item) => {
  275. if (item.id) {
  276. return createLibraryItemCompo({
  277. item,
  278. onClick: () => onInsertShape(item.elements),
  279. key: item.id,
  280. });
  281. }
  282. return createLibraryItemCompo({
  283. key: "__pending__item__",
  284. item,
  285. onClick: () => onAddToLibrary(pendingElements),
  286. });
  287. });
  288. // ensure we render all empty cells if no items are present
  289. let rows = chunk(_items, CELLS_PER_ROW);
  290. if (!rows.length) {
  291. rows = [[]];
  292. }
  293. return rows.map((rowItems, index, rows) => {
  294. if (index === rows.length - 1) {
  295. // pad row with empty cells
  296. rowItems = rowItems.concat(
  297. new Array(CELLS_PER_ROW - rowItems.length)
  298. .fill(null)
  299. .map((_, index) => {
  300. return createLibraryItemCompo({
  301. key: `empty_${index}`,
  302. item: null,
  303. });
  304. }),
  305. );
  306. }
  307. return (
  308. <Stack.Row align="center" gap={1} key={index}>
  309. {rowItems}
  310. </Stack.Row>
  311. );
  312. });
  313. };
  314. const unpublishedItems = libraryItems.filter(
  315. (item) => item.status !== "published",
  316. );
  317. const publishedItems = libraryItems.filter(
  318. (item) => item.status === "published",
  319. );
  320. return (
  321. <div className="library-menu-items-container">
  322. {showRemoveLibAlert && renderRemoveLibAlert()}
  323. <div className="layer-ui__library-header" key="library-header">
  324. {renderLibraryActions()}
  325. {isLoading ? (
  326. <Spinner />
  327. ) : (
  328. <a
  329. href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
  330. window.name || "_blank"
  331. }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
  332. VERSIONS.excalidrawLibrary
  333. }`}
  334. target="_excalidraw_libraries"
  335. >
  336. {t("labels.libraries")}
  337. </a>
  338. )}
  339. </div>
  340. <Stack.Col
  341. className="library-menu-items-container__items"
  342. align="start"
  343. gap={1}
  344. >
  345. <>
  346. <div className="separator">{t("labels.personalLib")}</div>
  347. {renderLibrarySection([
  348. // append pending library item
  349. ...(pendingElements.length
  350. ? [{ id: null, elements: pendingElements }]
  351. : []),
  352. ...unpublishedItems,
  353. ])}
  354. </>
  355. <>
  356. <div className="separator">{t("labels.excalidrawLib")} </div>
  357. {renderLibrarySection(publishedItems)}
  358. </>
  359. </Stack.Col>
  360. </div>
  361. );
  362. };
  363. export default LibraryMenuItems;