LibraryMenuItems.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import { chunk } from "lodash";
  2. import { useCallback, useState } from "react";
  3. import { importLibraryFromJSON, 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 { 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. const LibraryMenuItems = ({
  25. libraryItems,
  26. onRemoveFromLibrary,
  27. onAddToLibrary,
  28. onInsertShape,
  29. pendingElements,
  30. theme,
  31. setAppState,
  32. libraryReturnUrl,
  33. library,
  34. files,
  35. id,
  36. selectedItems,
  37. onToggle,
  38. onPublish,
  39. resetLibrary,
  40. }: {
  41. libraryItems: LibraryItems;
  42. pendingElements: LibraryItem["elements"];
  43. onRemoveFromLibrary: () => void;
  44. onInsertShape: (elements: LibraryItem["elements"]) => void;
  45. onAddToLibrary: (elements: LibraryItem["elements"]) => void;
  46. theme: AppState["theme"];
  47. files: BinaryFiles;
  48. setAppState: React.Component<any, AppState>["setState"];
  49. libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
  50. library: Library;
  51. id: string;
  52. selectedItems: LibraryItem["id"][];
  53. onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
  54. onPublish: () => void;
  55. resetLibrary: () => void;
  56. }) => {
  57. const renderRemoveLibAlert = useCallback(() => {
  58. const content = selectedItems.length
  59. ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
  60. : t("alerts.resetLibrary");
  61. const title = selectedItems.length
  62. ? t("confirmDialog.removeItemsFromLib")
  63. : t("confirmDialog.resetLibrary");
  64. return (
  65. <ConfirmDialog
  66. onConfirm={() => {
  67. if (selectedItems.length) {
  68. onRemoveFromLibrary();
  69. } else {
  70. resetLibrary();
  71. }
  72. setShowRemoveLibAlert(false);
  73. }}
  74. onCancel={() => {
  75. setShowRemoveLibAlert(false);
  76. }}
  77. title={title}
  78. >
  79. <p>{content}</p>
  80. </ConfirmDialog>
  81. );
  82. }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
  83. const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
  84. const isMobile = useDeviceType().isMobile;
  85. const renderLibraryActions = () => {
  86. const itemsSelected = !!selectedItems.length;
  87. const items = itemsSelected
  88. ? libraryItems.filter((item) => selectedItems.includes(item.id))
  89. : libraryItems;
  90. const resetLabel = itemsSelected
  91. ? t("buttons.remove")
  92. : t("buttons.resetLibrary");
  93. return (
  94. <div className="library-actions">
  95. {(!itemsSelected || !isMobile) && (
  96. <ToolButton
  97. key="import"
  98. type="button"
  99. title={t("buttons.load")}
  100. aria-label={t("buttons.load")}
  101. icon={load}
  102. onClick={() => {
  103. importLibraryFromJSON(library)
  104. .catch(muteFSAbortError)
  105. .catch((error) => {
  106. setAppState({ errorMessage: error.message });
  107. });
  108. }}
  109. className="library-actions--load"
  110. />
  111. )}
  112. {!!items.length && (
  113. <>
  114. <ToolButton
  115. key="export"
  116. type="button"
  117. title={t("buttons.export")}
  118. aria-label={t("buttons.export")}
  119. icon={exportToFileIcon}
  120. onClick={async () => {
  121. const libraryItems = itemsSelected
  122. ? items
  123. : await library.loadLibrary();
  124. saveLibraryAsJSON(libraryItems)
  125. .catch(muteFSAbortError)
  126. .catch((error) => {
  127. setAppState({ errorMessage: error.message });
  128. });
  129. }}
  130. className="library-actions--export"
  131. >
  132. {selectedItems.length > 0 && (
  133. <span className="library-actions-counter">
  134. {selectedItems.length}
  135. </span>
  136. )}
  137. </ToolButton>
  138. <ToolButton
  139. key="reset"
  140. type="button"
  141. title={resetLabel}
  142. aria-label={resetLabel}
  143. icon={trash}
  144. onClick={() => setShowRemoveLibAlert(true)}
  145. className="library-actions--remove"
  146. >
  147. {selectedItems.length > 0 && (
  148. <span className="library-actions-counter">
  149. {selectedItems.length}
  150. </span>
  151. )}
  152. </ToolButton>
  153. </>
  154. )}
  155. {itemsSelected && !isPublished && (
  156. <Tooltip label={t("hints.publishLibrary")}>
  157. <ToolButton
  158. type="button"
  159. aria-label={t("buttons.publishLibrary")}
  160. label={t("buttons.publishLibrary")}
  161. icon={publishIcon}
  162. className="library-actions--publish"
  163. onClick={onPublish}
  164. >
  165. {!isMobile && <label>{t("buttons.publishLibrary")}</label>}
  166. {selectedItems.length > 0 && (
  167. <span className="library-actions-counter">
  168. {selectedItems.length}
  169. </span>
  170. )}
  171. </ToolButton>
  172. </Tooltip>
  173. )}
  174. </div>
  175. );
  176. };
  177. const CELLS_PER_ROW = isMobile ? 4 : 6;
  178. const referrer =
  179. libraryReturnUrl || window.location.origin + window.location.pathname;
  180. const isPublished = selectedItems.some(
  181. (id) => libraryItems.find((item) => item.id === id)?.status === "published",
  182. );
  183. const createLibraryItemCompo = (params: {
  184. item:
  185. | LibraryItem
  186. | /* pending library item */ {
  187. id: null;
  188. elements: readonly NonDeleted<ExcalidrawElement>[];
  189. }
  190. | null;
  191. onClick?: () => void;
  192. key: string;
  193. }) => {
  194. return (
  195. <Stack.Col key={params.key}>
  196. <LibraryUnit
  197. elements={params.item?.elements}
  198. files={files}
  199. isPending={!params.item?.id && !!params.item?.elements}
  200. onClick={params.onClick || (() => {})}
  201. id={params.item?.id || null}
  202. selected={!!params.item?.id && selectedItems.includes(params.item.id)}
  203. onToggle={(id, event) => {
  204. onToggle(id, event);
  205. }}
  206. />
  207. </Stack.Col>
  208. );
  209. };
  210. const renderLibrarySection = (
  211. items: (
  212. | LibraryItem
  213. | /* pending library item */ {
  214. id: null;
  215. elements: readonly NonDeleted<ExcalidrawElement>[];
  216. }
  217. )[],
  218. ) => {
  219. const _items = items.map((item) => {
  220. if (item.id) {
  221. return createLibraryItemCompo({
  222. item,
  223. onClick: () => onInsertShape(item.elements),
  224. key: item.id,
  225. });
  226. }
  227. return createLibraryItemCompo({
  228. key: "__pending__item__",
  229. item,
  230. onClick: () => onAddToLibrary(pendingElements),
  231. });
  232. });
  233. // ensure we render all empty cells if no items are present
  234. let rows = chunk(_items, CELLS_PER_ROW);
  235. if (!rows.length) {
  236. rows = [[]];
  237. }
  238. return rows.map((rowItems, index, rows) => {
  239. if (index === rows.length - 1) {
  240. // pad row with empty cells
  241. rowItems = rowItems.concat(
  242. new Array(CELLS_PER_ROW - rowItems.length)
  243. .fill(null)
  244. .map((_, index) => {
  245. return createLibraryItemCompo({
  246. key: `empty_${index}`,
  247. item: null,
  248. });
  249. }),
  250. );
  251. }
  252. return (
  253. <Stack.Row align="center" gap={1} key={index}>
  254. {rowItems}
  255. </Stack.Row>
  256. );
  257. });
  258. };
  259. const publishedItems = libraryItems.filter(
  260. (item) => item.status === "published",
  261. );
  262. const unpublishedItems = [
  263. // append pending library item
  264. ...(pendingElements.length
  265. ? [{ id: null, elements: pendingElements }]
  266. : []),
  267. ...libraryItems.filter((item) => item.status !== "published"),
  268. ];
  269. return (
  270. <div className="library-menu-items-container">
  271. {showRemoveLibAlert && renderRemoveLibAlert()}
  272. <div className="layer-ui__library-header" key="library-header">
  273. {renderLibraryActions()}
  274. <a
  275. href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
  276. window.name || "_blank"
  277. }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
  278. VERSIONS.excalidrawLibrary
  279. }`}
  280. target="_excalidraw_libraries"
  281. >
  282. {t("labels.libraries")}
  283. </a>
  284. </div>
  285. <Stack.Col
  286. className="library-menu-items-container__items"
  287. align="start"
  288. gap={1}
  289. >
  290. <>
  291. <div className="separator">{t("labels.personalLib")}</div>
  292. {renderLibrarySection(unpublishedItems)}
  293. </>
  294. <>
  295. <div className="separator">{t("labels.excalidrawLib")} </div>
  296. {renderLibrarySection(publishedItems)}
  297. </>
  298. </Stack.Col>
  299. </div>
  300. );
  301. };
  302. export default LibraryMenuItems;