LibraryMenu.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  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. setAppState,
  76. files,
  77. libraryReturnUrl,
  78. focusContainer,
  79. library,
  80. id,
  81. appState,
  82. }: {
  83. pendingElements: LibraryItem["elements"];
  84. onClose: () => void;
  85. onInsertLibraryItems: (libraryItems: LibraryItems) => void;
  86. onAddToLibrary: () => void;
  87. files: BinaryFiles;
  88. setAppState: React.Component<any, AppState>["setState"];
  89. libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
  90. focusContainer: () => void;
  91. library: Library;
  92. id: string;
  93. appState: AppState;
  94. }) => {
  95. const ref = useRef<HTMLDivElement | null>(null);
  96. const device = useDevice();
  97. useOnClickOutside(
  98. ref,
  99. useCallback(
  100. (event) => {
  101. // If click on the library icon, do nothing so that LibraryButton
  102. // can toggle library menu
  103. if ((event.target as Element).closest(".ToolIcon__library")) {
  104. return;
  105. }
  106. if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
  107. onClose();
  108. }
  109. },
  110. [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
  111. ),
  112. );
  113. useEffect(() => {
  114. const handleKeyDown = (event: KeyboardEvent) => {
  115. if (
  116. event.key === KEYS.ESCAPE &&
  117. (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
  118. ) {
  119. onClose();
  120. }
  121. };
  122. document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
  123. return () => {
  124. document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
  125. };
  126. }, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
  127. const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
  128. const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
  129. useState(false);
  130. const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
  131. url: string;
  132. authorName: string;
  133. }>(null);
  134. const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
  135. const removeFromLibrary = useCallback(
  136. async (libraryItems: LibraryItems) => {
  137. const nextItems = libraryItems.filter(
  138. (item) => !selectedItems.includes(item.id),
  139. );
  140. library.setLibrary(nextItems).catch(() => {
  141. setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
  142. });
  143. setSelectedItems([]);
  144. },
  145. [library, setAppState, selectedItems, setSelectedItems],
  146. );
  147. const resetLibrary = useCallback(() => {
  148. library.resetLibrary();
  149. focusContainer();
  150. }, [library, focusContainer]);
  151. const addToLibrary = useCallback(
  152. async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
  153. trackEvent("element", "addToLibrary", "ui");
  154. if (elements.some((element) => element.type === "image")) {
  155. return setAppState({
  156. errorMessage: "Support for adding images to the library coming soon!",
  157. });
  158. }
  159. const nextItems: LibraryItems = [
  160. {
  161. status: "unpublished",
  162. elements,
  163. id: randomId(),
  164. created: Date.now(),
  165. },
  166. ...libraryItems,
  167. ];
  168. onAddToLibrary();
  169. library.setLibrary(nextItems).catch(() => {
  170. setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
  171. });
  172. },
  173. [onAddToLibrary, library, setAppState],
  174. );
  175. const renderPublishSuccess = useCallback(() => {
  176. return (
  177. <Dialog
  178. onCloseRequest={() => setPublishLibSuccess(null)}
  179. title={t("publishSuccessDialog.title")}
  180. className="publish-library-success"
  181. small={true}
  182. >
  183. <p>
  184. {t("publishSuccessDialog.content", {
  185. authorName: publishLibSuccess!.authorName,
  186. })}{" "}
  187. <a
  188. href={publishLibSuccess?.url}
  189. target="_blank"
  190. rel="noopener noreferrer"
  191. >
  192. {t("publishSuccessDialog.link")}
  193. </a>
  194. </p>
  195. <ToolButton
  196. type="button"
  197. title={t("buttons.close")}
  198. aria-label={t("buttons.close")}
  199. label={t("buttons.close")}
  200. onClick={() => setPublishLibSuccess(null)}
  201. data-testid="publish-library-success-close"
  202. className="publish-library-success-close"
  203. />
  204. </Dialog>
  205. );
  206. }, [setPublishLibSuccess, publishLibSuccess]);
  207. const onPublishLibSuccess = useCallback(
  208. (data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
  209. setShowPublishLibraryDialog(false);
  210. setPublishLibSuccess({ url: data.url, authorName: data.authorName });
  211. const nextLibItems = libraryItems.slice();
  212. nextLibItems.forEach((libItem) => {
  213. if (selectedItems.includes(libItem.id)) {
  214. libItem.status = "published";
  215. }
  216. });
  217. library.setLibrary(nextLibItems);
  218. },
  219. [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
  220. );
  221. if (
  222. libraryItemsData.status === "loading" &&
  223. !libraryItemsData.isInitialized
  224. ) {
  225. return (
  226. <LibraryMenuWrapper ref={ref}>
  227. <div className="layer-ui__library-message">
  228. <Spinner size="2em" />
  229. <span>{t("labels.libraryLoadingMessage")}</span>
  230. </div>
  231. </LibraryMenuWrapper>
  232. );
  233. }
  234. return (
  235. <LibraryMenuWrapper ref={ref}>
  236. {showPublishLibraryDialog && (
  237. <PublishLibrary
  238. onClose={() => setShowPublishLibraryDialog(false)}
  239. libraryItems={getSelectedItems(
  240. libraryItemsData.libraryItems,
  241. selectedItems,
  242. )}
  243. appState={appState}
  244. onSuccess={(data) =>
  245. onPublishLibSuccess(data, libraryItemsData.libraryItems)
  246. }
  247. onError={(error) => window.alert(error)}
  248. updateItemsInStorage={() =>
  249. library.setLibrary(libraryItemsData.libraryItems)
  250. }
  251. onRemove={(id: string) =>
  252. setSelectedItems(selectedItems.filter((_id) => _id !== id))
  253. }
  254. />
  255. )}
  256. {publishLibSuccess && renderPublishSuccess()}
  257. <LibraryMenuItems
  258. isLoading={libraryItemsData.status === "loading"}
  259. libraryItems={libraryItemsData.libraryItems}
  260. onRemoveFromLibrary={() =>
  261. removeFromLibrary(libraryItemsData.libraryItems)
  262. }
  263. onAddToLibrary={(elements) =>
  264. addToLibrary(elements, libraryItemsData.libraryItems)
  265. }
  266. onInsertLibraryItems={onInsertLibraryItems}
  267. pendingElements={pendingElements}
  268. setAppState={setAppState}
  269. appState={appState}
  270. libraryReturnUrl={libraryReturnUrl}
  271. library={library}
  272. theme={appState.theme}
  273. files={files}
  274. id={id}
  275. selectedItems={selectedItems}
  276. onSelectItems={(ids) => setSelectedItems(ids)}
  277. onPublish={() => setShowPublishLibraryDialog(true)}
  278. resetLibrary={resetLibrary}
  279. />
  280. </LibraryMenuWrapper>
  281. );
  282. };