LibraryMenu.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import {
  2. useRef,
  3. useState,
  4. useEffect,
  5. useCallback,
  6. RefObject,
  7. forwardRef,
  8. } from "react";
  9. import Library, { libraryItemsAtom } from "../data/library";
  10. import { t } from "../i18n";
  11. import { randomId } from "../random";
  12. import {
  13. LibraryItems,
  14. LibraryItem,
  15. AppState,
  16. BinaryFiles,
  17. ExcalidrawProps,
  18. } from "../types";
  19. import { Dialog } from "./Dialog";
  20. import { Island } from "./Island";
  21. import PublishLibrary from "./PublishLibrary";
  22. import { ToolButton } from "./ToolButton";
  23. import "./LibraryMenu.scss";
  24. import LibraryMenuItems from "./LibraryMenuItems";
  25. import { EVENT } from "../constants";
  26. import { KEYS } from "../keys";
  27. import { trackEvent } from "../analytics";
  28. import { useAtom } from "jotai";
  29. import { jotaiScope } from "../jotai";
  30. import Spinner from "./Spinner";
  31. import { useDevice } from "./App";
  32. const useOnClickOutside = (
  33. ref: RefObject<HTMLElement>,
  34. cb: (event: MouseEvent) => void,
  35. ) => {
  36. useEffect(() => {
  37. const listener = (event: MouseEvent) => {
  38. if (!ref.current) {
  39. return;
  40. }
  41. if (
  42. event.target instanceof Element &&
  43. (ref.current.contains(event.target) ||
  44. !document.body.contains(event.target))
  45. ) {
  46. return;
  47. }
  48. cb(event);
  49. };
  50. document.addEventListener("pointerdown", listener, false);
  51. return () => {
  52. document.removeEventListener("pointerdown", listener);
  53. };
  54. }, [ref, cb]);
  55. };
  56. const getSelectedItems = (
  57. libraryItems: LibraryItems,
  58. selectedItems: LibraryItem["id"][],
  59. ) => libraryItems.filter((item) => selectedItems.includes(item.id));
  60. const LibraryMenuWrapper = forwardRef<
  61. HTMLDivElement,
  62. { children: React.ReactNode }
  63. >(({ children }, ref) => {
  64. return (
  65. <Island padding={1} ref={ref} className="layer-ui__library">
  66. {children}
  67. </Island>
  68. );
  69. });
  70. export const LibraryMenu = ({
  71. onClose,
  72. onInsertLibraryItems,
  73. pendingElements,
  74. onAddToLibrary,
  75. theme,
  76. setAppState,
  77. files,
  78. libraryReturnUrl,
  79. focusContainer,
  80. library,
  81. id,
  82. appState,
  83. }: {
  84. pendingElements: LibraryItem["elements"];
  85. onClose: () => void;
  86. onInsertLibraryItems: (libraryItems: LibraryItems) => void;
  87. onAddToLibrary: () => void;
  88. theme: AppState["theme"];
  89. files: BinaryFiles;
  90. setAppState: React.Component<any, AppState>["setState"];
  91. libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
  92. focusContainer: () => void;
  93. library: Library;
  94. id: string;
  95. appState: AppState;
  96. }) => {
  97. const ref = useRef<HTMLDivElement | null>(null);
  98. const device = useDevice();
  99. useOnClickOutside(
  100. ref,
  101. useCallback(
  102. (event) => {
  103. // If click on the library icon, do nothing.
  104. if ((event.target as Element).closest(".ToolIcon__library")) {
  105. return;
  106. }
  107. if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
  108. onClose();
  109. }
  110. },
  111. [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
  112. ),
  113. );
  114. useEffect(() => {
  115. const handleKeyDown = (event: KeyboardEvent) => {
  116. if (
  117. event.key === KEYS.ESCAPE &&
  118. (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
  119. ) {
  120. onClose();
  121. }
  122. };
  123. document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
  124. return () => {
  125. document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
  126. };
  127. }, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
  128. const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
  129. const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
  130. useState(false);
  131. const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
  132. url: string;
  133. authorName: string;
  134. }>(null);
  135. const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
  136. const removeFromLibrary = useCallback(
  137. async (libraryItems: LibraryItems) => {
  138. const nextItems = libraryItems.filter(
  139. (item) => !selectedItems.includes(item.id),
  140. );
  141. library.setLibrary(nextItems).catch(() => {
  142. setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
  143. });
  144. setSelectedItems([]);
  145. },
  146. [library, setAppState, selectedItems, setSelectedItems],
  147. );
  148. const resetLibrary = useCallback(() => {
  149. library.resetLibrary();
  150. focusContainer();
  151. }, [library, focusContainer]);
  152. const addToLibrary = useCallback(
  153. async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
  154. trackEvent("element", "addToLibrary", "ui");
  155. if (elements.some((element) => element.type === "image")) {
  156. return setAppState({
  157. errorMessage: "Support for adding images to the library coming soon!",
  158. });
  159. }
  160. const nextItems: LibraryItems = [
  161. {
  162. status: "unpublished",
  163. elements,
  164. id: randomId(),
  165. created: Date.now(),
  166. },
  167. ...libraryItems,
  168. ];
  169. onAddToLibrary();
  170. library.setLibrary(nextItems).catch(() => {
  171. setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
  172. });
  173. },
  174. [onAddToLibrary, library, setAppState],
  175. );
  176. const renderPublishSuccess = useCallback(() => {
  177. return (
  178. <Dialog
  179. onCloseRequest={() => setPublishLibSuccess(null)}
  180. title={t("publishSuccessDialog.title")}
  181. className="publish-library-success"
  182. small={true}
  183. >
  184. <p>
  185. {t("publishSuccessDialog.content", {
  186. authorName: publishLibSuccess!.authorName,
  187. })}{" "}
  188. <a
  189. href={publishLibSuccess?.url}
  190. target="_blank"
  191. rel="noopener noreferrer"
  192. >
  193. {t("publishSuccessDialog.link")}
  194. </a>
  195. </p>
  196. <ToolButton
  197. type="button"
  198. title={t("buttons.close")}
  199. aria-label={t("buttons.close")}
  200. label={t("buttons.close")}
  201. onClick={() => setPublishLibSuccess(null)}
  202. data-testid="publish-library-success-close"
  203. className="publish-library-success-close"
  204. />
  205. </Dialog>
  206. );
  207. }, [setPublishLibSuccess, publishLibSuccess]);
  208. const onPublishLibSuccess = useCallback(
  209. (data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
  210. setShowPublishLibraryDialog(false);
  211. setPublishLibSuccess({ url: data.url, authorName: data.authorName });
  212. const nextLibItems = libraryItems.slice();
  213. nextLibItems.forEach((libItem) => {
  214. if (selectedItems.includes(libItem.id)) {
  215. libItem.status = "published";
  216. }
  217. });
  218. library.setLibrary(nextLibItems);
  219. },
  220. [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
  221. );
  222. if (
  223. libraryItemsData.status === "loading" &&
  224. !libraryItemsData.isInitialized
  225. ) {
  226. return (
  227. <LibraryMenuWrapper ref={ref}>
  228. <div className="layer-ui__library-message">
  229. <Spinner size="2em" />
  230. <span>{t("labels.libraryLoadingMessage")}</span>
  231. </div>
  232. </LibraryMenuWrapper>
  233. );
  234. }
  235. return (
  236. <LibraryMenuWrapper ref={ref}>
  237. {showPublishLibraryDialog && (
  238. <PublishLibrary
  239. onClose={() => setShowPublishLibraryDialog(false)}
  240. libraryItems={getSelectedItems(
  241. libraryItemsData.libraryItems,
  242. selectedItems,
  243. )}
  244. appState={appState}
  245. onSuccess={(data) =>
  246. onPublishLibSuccess(data, libraryItemsData.libraryItems)
  247. }
  248. onError={(error) => window.alert(error)}
  249. updateItemsInStorage={() =>
  250. library.setLibrary(libraryItemsData.libraryItems)
  251. }
  252. onRemove={(id: string) =>
  253. setSelectedItems(selectedItems.filter((_id) => _id !== id))
  254. }
  255. />
  256. )}
  257. {publishLibSuccess && renderPublishSuccess()}
  258. <LibraryMenuItems
  259. isLoading={libraryItemsData.status === "loading"}
  260. libraryItems={libraryItemsData.libraryItems}
  261. onRemoveFromLibrary={() =>
  262. removeFromLibrary(libraryItemsData.libraryItems)
  263. }
  264. onAddToLibrary={(elements) =>
  265. addToLibrary(elements, libraryItemsData.libraryItems)
  266. }
  267. onInsertLibraryItems={onInsertLibraryItems}
  268. pendingElements={pendingElements}
  269. setAppState={setAppState}
  270. appState={appState}
  271. libraryReturnUrl={libraryReturnUrl}
  272. library={library}
  273. theme={theme}
  274. files={files}
  275. id={id}
  276. selectedItems={selectedItems}
  277. onSelectItems={(ids) => setSelectedItems(ids)}
  278. onPublish={() => setShowPublishLibraryDialog(true)}
  279. resetLibrary={resetLibrary}
  280. />
  281. </LibraryMenuWrapper>
  282. );
  283. };