LibraryMenu.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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 { arrayToMap } from "../utils";
  28. import { trackEvent } from "../analytics";
  29. import { useAtom } from "jotai";
  30. import { jotaiScope } from "../jotai";
  31. import Spinner from "./Spinner";
  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. onInsertShape,
  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. onInsertShape: (elements: LibraryItem["elements"]) => 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. useOnClickOutside(ref, (event) => {
  99. // If click on the library icon, do nothing.
  100. if ((event.target as Element).closest(".ToolIcon__library")) {
  101. return;
  102. }
  103. onClose();
  104. });
  105. useEffect(() => {
  106. const handleKeyDown = (event: KeyboardEvent) => {
  107. if (event.key === KEYS.ESCAPE) {
  108. onClose();
  109. }
  110. };
  111. document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
  112. return () => {
  113. document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
  114. };
  115. }, [onClose]);
  116. const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
  117. const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
  118. useState(false);
  119. const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
  120. url: string;
  121. authorName: string;
  122. }>(null);
  123. const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
  124. const removeFromLibrary = useCallback(
  125. async (libraryItems: LibraryItems) => {
  126. const nextItems = libraryItems.filter(
  127. (item) => !selectedItems.includes(item.id),
  128. );
  129. library.saveLibrary(nextItems).catch(() => {
  130. setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
  131. });
  132. setSelectedItems([]);
  133. },
  134. [library, setAppState, selectedItems, setSelectedItems],
  135. );
  136. const resetLibrary = useCallback(() => {
  137. library.resetLibrary();
  138. focusContainer();
  139. }, [library, focusContainer]);
  140. const addToLibrary = useCallback(
  141. async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
  142. trackEvent("element", "addToLibrary", "ui");
  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 nextItems: LibraryItems = [
  149. {
  150. status: "unpublished",
  151. elements,
  152. id: randomId(),
  153. created: Date.now(),
  154. },
  155. ...libraryItems,
  156. ];
  157. onAddToLibrary();
  158. library.saveLibrary(nextItems).catch(() => {
  159. setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
  160. });
  161. },
  162. [onAddToLibrary, library, setAppState],
  163. );
  164. const renderPublishSuccess = useCallback(() => {
  165. return (
  166. <Dialog
  167. onCloseRequest={() => setPublishLibSuccess(null)}
  168. title={t("publishSuccessDialog.title")}
  169. className="publish-library-success"
  170. small={true}
  171. >
  172. <p>
  173. {t("publishSuccessDialog.content", {
  174. authorName: publishLibSuccess!.authorName,
  175. })}{" "}
  176. <a
  177. href={publishLibSuccess?.url}
  178. target="_blank"
  179. rel="noopener noreferrer"
  180. >
  181. {t("publishSuccessDialog.link")}
  182. </a>
  183. </p>
  184. <ToolButton
  185. type="button"
  186. title={t("buttons.close")}
  187. aria-label={t("buttons.close")}
  188. label={t("buttons.close")}
  189. onClick={() => setPublishLibSuccess(null)}
  190. data-testid="publish-library-success-close"
  191. className="publish-library-success-close"
  192. />
  193. </Dialog>
  194. );
  195. }, [setPublishLibSuccess, publishLibSuccess]);
  196. const onPublishLibSuccess = useCallback(
  197. (data, libraryItems: LibraryItems) => {
  198. setShowPublishLibraryDialog(false);
  199. setPublishLibSuccess({ url: data.url, authorName: data.authorName });
  200. const nextLibItems = libraryItems.slice();
  201. nextLibItems.forEach((libItem) => {
  202. if (selectedItems.includes(libItem.id)) {
  203. libItem.status = "published";
  204. }
  205. });
  206. library.saveLibrary(nextLibItems);
  207. },
  208. [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
  209. );
  210. const [lastSelectedItem, setLastSelectedItem] = useState<
  211. LibraryItem["id"] | null
  212. >(null);
  213. if (libraryItemsData.status === "loading") {
  214. return (
  215. <LibraryMenuWrapper ref={ref}>
  216. <div className="layer-ui__library-message">
  217. <Spinner size="2em" />
  218. <span>{t("labels.libraryLoadingMessage")}</span>
  219. </div>
  220. </LibraryMenuWrapper>
  221. );
  222. }
  223. return (
  224. <LibraryMenuWrapper ref={ref}>
  225. {showPublishLibraryDialog && (
  226. <PublishLibrary
  227. onClose={() => setShowPublishLibraryDialog(false)}
  228. libraryItems={getSelectedItems(
  229. libraryItemsData.libraryItems,
  230. selectedItems,
  231. )}
  232. appState={appState}
  233. onSuccess={(data) =>
  234. onPublishLibSuccess(data, libraryItemsData.libraryItems)
  235. }
  236. onError={(error) => window.alert(error)}
  237. updateItemsInStorage={() =>
  238. library.saveLibrary(libraryItemsData.libraryItems)
  239. }
  240. onRemove={(id: string) =>
  241. setSelectedItems(selectedItems.filter((_id) => _id !== id))
  242. }
  243. />
  244. )}
  245. {publishLibSuccess && renderPublishSuccess()}
  246. <LibraryMenuItems
  247. libraryItems={libraryItemsData.libraryItems}
  248. onRemoveFromLibrary={() =>
  249. removeFromLibrary(libraryItemsData.libraryItems)
  250. }
  251. onAddToLibrary={(elements) =>
  252. addToLibrary(elements, libraryItemsData.libraryItems)
  253. }
  254. onInsertShape={onInsertShape}
  255. pendingElements={pendingElements}
  256. setAppState={setAppState}
  257. libraryReturnUrl={libraryReturnUrl}
  258. library={library}
  259. theme={theme}
  260. files={files}
  261. id={id}
  262. selectedItems={selectedItems}
  263. onToggle={(id, event) => {
  264. const shouldSelect = !selectedItems.includes(id);
  265. if (shouldSelect) {
  266. if (event.shiftKey && lastSelectedItem) {
  267. const rangeStart = libraryItemsData.libraryItems.findIndex(
  268. (item) => item.id === lastSelectedItem,
  269. );
  270. const rangeEnd = libraryItemsData.libraryItems.findIndex(
  271. (item) => item.id === id,
  272. );
  273. if (rangeStart === -1 || rangeEnd === -1) {
  274. setSelectedItems([...selectedItems, id]);
  275. return;
  276. }
  277. const selectedItemsMap = arrayToMap(selectedItems);
  278. const nextSelectedIds = libraryItemsData.libraryItems.reduce(
  279. (acc: LibraryItem["id"][], item, idx) => {
  280. if (
  281. (idx >= rangeStart && idx <= rangeEnd) ||
  282. selectedItemsMap.has(item.id)
  283. ) {
  284. acc.push(item.id);
  285. }
  286. return acc;
  287. },
  288. [],
  289. );
  290. setSelectedItems(nextSelectedIds);
  291. } else {
  292. setSelectedItems([...selectedItems, id]);
  293. }
  294. setLastSelectedItem(id);
  295. } else {
  296. setLastSelectedItem(null);
  297. setSelectedItems(selectedItems.filter((_id) => _id !== id));
  298. }
  299. }}
  300. onPublish={() => setShowPublishLibraryDialog(true)}
  301. resetLibrary={resetLibrary}
  302. />
  303. </LibraryMenuWrapper>
  304. );
  305. };