LibraryMenu.tsx 9.5 KB

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