LibraryMenu.tsx 8.1 KB

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