PublishLibrary.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import { ReactNode, useCallback, useEffect, useState } from "react";
  2. import OpenColor from "open-color";
  3. import { Dialog } from "./Dialog";
  4. import { t } from "../i18n";
  5. import { AppState, LibraryItems, LibraryItem } from "../types";
  6. import { exportToCanvas } from "../packages/utils";
  7. import {
  8. EXPORT_DATA_TYPES,
  9. EXPORT_SOURCE,
  10. MIME_TYPES,
  11. VERSIONS,
  12. } from "../constants";
  13. import { ExportedLibraryData } from "../data/types";
  14. import "./PublishLibrary.scss";
  15. import SingleLibraryItem from "./SingleLibraryItem";
  16. import { canvasToBlob, resizeImageFile } from "../data/blob";
  17. import { chunk } from "../utils";
  18. import DialogActionButton from "./DialogActionButton";
  19. interface PublishLibraryDataParams {
  20. authorName: string;
  21. githubHandle: string;
  22. name: string;
  23. description: string;
  24. twitterHandle: string;
  25. website: string;
  26. }
  27. const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
  28. const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
  29. try {
  30. localStorage.setItem(
  31. LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
  32. JSON.stringify(data),
  33. );
  34. } catch (error: any) {
  35. // Unable to access window.localStorage
  36. console.error(error);
  37. }
  38. };
  39. const importPublishLibDataFromStorage = () => {
  40. try {
  41. const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
  42. if (data) {
  43. return JSON.parse(data);
  44. }
  45. } catch (error: any) {
  46. // Unable to access localStorage
  47. console.error(error);
  48. }
  49. return null;
  50. };
  51. const generatePreviewImage = async (libraryItems: LibraryItems) => {
  52. const MAX_ITEMS_PER_ROW = 6;
  53. const BOX_SIZE = 128;
  54. const BOX_PADDING = Math.round(BOX_SIZE / 16);
  55. const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
  56. const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
  57. const canvas = document.createElement("canvas");
  58. canvas.width =
  59. rows[0].length * BOX_SIZE +
  60. (rows[0].length + 1) * (BOX_PADDING * 2) -
  61. BOX_PADDING * 2;
  62. canvas.height =
  63. rows.length * BOX_SIZE +
  64. (rows.length + 1) * (BOX_PADDING * 2) -
  65. BOX_PADDING * 2;
  66. const ctx = canvas.getContext("2d")!;
  67. ctx.fillStyle = OpenColor.white;
  68. ctx.fillRect(0, 0, canvas.width, canvas.height);
  69. // draw items
  70. // ---------------------------------------------------------------------------
  71. for (const [index, item] of libraryItems.entries()) {
  72. const itemCanvas = await exportToCanvas({
  73. elements: item.elements,
  74. files: null,
  75. maxWidthOrHeight: BOX_SIZE,
  76. });
  77. const { width, height } = itemCanvas;
  78. // draw item
  79. // -------------------------------------------------------------------------
  80. const rowOffset =
  81. Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
  82. const colOffset =
  83. (index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
  84. ctx.drawImage(
  85. itemCanvas,
  86. colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
  87. rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
  88. );
  89. // draw item border
  90. // -------------------------------------------------------------------------
  91. ctx.lineWidth = BORDER_WIDTH;
  92. ctx.strokeStyle = OpenColor.gray[4];
  93. ctx.strokeRect(
  94. colOffset + BOX_PADDING / 2,
  95. rowOffset + BOX_PADDING / 2,
  96. BOX_SIZE + BOX_PADDING,
  97. BOX_SIZE + BOX_PADDING,
  98. );
  99. }
  100. return await resizeImageFile(
  101. new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
  102. {
  103. outputType: MIME_TYPES.jpg,
  104. maxWidthOrHeight: 5000,
  105. },
  106. );
  107. };
  108. const PublishLibrary = ({
  109. onClose,
  110. libraryItems,
  111. appState,
  112. onSuccess,
  113. onError,
  114. updateItemsInStorage,
  115. onRemove,
  116. }: {
  117. onClose: () => void;
  118. libraryItems: LibraryItems;
  119. appState: AppState;
  120. onSuccess: (data: {
  121. url: string;
  122. authorName: string;
  123. items: LibraryItems;
  124. }) => void;
  125. onError: (error: Error) => void;
  126. updateItemsInStorage: (items: LibraryItems) => void;
  127. onRemove: (id: string) => void;
  128. }) => {
  129. const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
  130. authorName: "",
  131. githubHandle: "",
  132. name: "",
  133. description: "",
  134. twitterHandle: "",
  135. website: "",
  136. });
  137. const [isSubmitting, setIsSubmitting] = useState(false);
  138. useEffect(() => {
  139. const data = importPublishLibDataFromStorage();
  140. if (data) {
  141. setLibraryData(data);
  142. }
  143. }, []);
  144. const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
  145. libraryItems.slice(),
  146. );
  147. useEffect(() => {
  148. setClonedLibItems(libraryItems.slice());
  149. }, [libraryItems]);
  150. const onInputChange = (event: any) => {
  151. setLibraryData({
  152. ...libraryData,
  153. [event.target.name]: event.target.value,
  154. });
  155. };
  156. const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
  157. event.preventDefault();
  158. setIsSubmitting(true);
  159. const erroredLibItems: LibraryItem[] = [];
  160. let isError = false;
  161. clonedLibItems.forEach((libItem) => {
  162. let error = "";
  163. if (!libItem.name) {
  164. error = t("publishDialog.errors.required");
  165. isError = true;
  166. }
  167. erroredLibItems.push({ ...libItem, error });
  168. });
  169. if (isError) {
  170. setClonedLibItems(erroredLibItems);
  171. setIsSubmitting(false);
  172. return;
  173. }
  174. const previewImage = await generatePreviewImage(clonedLibItems);
  175. const libContent: ExportedLibraryData = {
  176. type: EXPORT_DATA_TYPES.excalidrawLibrary,
  177. version: VERSIONS.excalidrawLibrary,
  178. source: EXPORT_SOURCE,
  179. libraryItems: clonedLibItems,
  180. };
  181. const content = JSON.stringify(libContent, null, 2);
  182. const lib = new Blob([content], { type: "application/json" });
  183. const formData = new FormData();
  184. formData.append("excalidrawLib", lib);
  185. formData.append("previewImage", previewImage);
  186. formData.append("previewImageType", previewImage.type);
  187. formData.append("title", libraryData.name);
  188. formData.append("authorName", libraryData.authorName);
  189. formData.append("githubHandle", libraryData.githubHandle);
  190. formData.append("name", libraryData.name);
  191. formData.append("description", libraryData.description);
  192. formData.append("twitterHandle", libraryData.twitterHandle);
  193. formData.append("website", libraryData.website);
  194. fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
  195. method: "post",
  196. body: formData,
  197. })
  198. .then(
  199. (response) => {
  200. if (response.ok) {
  201. return response.json().then(({ url }) => {
  202. // flush data from local storage
  203. localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
  204. onSuccess({
  205. url,
  206. authorName: libraryData.authorName,
  207. items: clonedLibItems,
  208. });
  209. });
  210. }
  211. return response
  212. .json()
  213. .catch(() => {
  214. throw new Error(response.statusText || "something went wrong");
  215. })
  216. .then((error) => {
  217. throw new Error(
  218. error.message || response.statusText || "something went wrong",
  219. );
  220. });
  221. },
  222. (err) => {
  223. console.error(err);
  224. onError(err);
  225. setIsSubmitting(false);
  226. },
  227. )
  228. .catch((err) => {
  229. console.error(err);
  230. onError(err);
  231. setIsSubmitting(false);
  232. });
  233. };
  234. const renderLibraryItems = () => {
  235. const items: ReactNode[] = [];
  236. clonedLibItems.forEach((libItem, index) => {
  237. items.push(
  238. <div className="single-library-item-wrapper" key={index}>
  239. <SingleLibraryItem
  240. libItem={libItem}
  241. appState={appState}
  242. index={index}
  243. onChange={(val, index) => {
  244. const items = clonedLibItems.slice();
  245. items[index].name = val;
  246. setClonedLibItems(items);
  247. }}
  248. onRemove={onRemove}
  249. />
  250. </div>,
  251. );
  252. });
  253. return <div className="selected-library-items">{items}</div>;
  254. };
  255. const onDialogClose = useCallback(() => {
  256. updateItemsInStorage(clonedLibItems);
  257. savePublishLibDataToStorage(libraryData);
  258. onClose();
  259. }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
  260. const shouldRenderForm = !!libraryItems.length;
  261. const containsPublishedItems = libraryItems.some(
  262. (item) => item.status === "published",
  263. );
  264. return (
  265. <Dialog
  266. onCloseRequest={onDialogClose}
  267. title={t("publishDialog.title")}
  268. className="publish-library"
  269. >
  270. {shouldRenderForm ? (
  271. <form onSubmit={onSubmit}>
  272. <div className="publish-library-note">
  273. {t("publishDialog.noteDescription.pre")}
  274. <a
  275. href="https://libraries.excalidraw.com"
  276. target="_blank"
  277. rel="noopener noreferrer"
  278. >
  279. {t("publishDialog.noteDescription.link")}
  280. </a>{" "}
  281. {t("publishDialog.noteDescription.post")}
  282. </div>
  283. <span className="publish-library-note">
  284. {t("publishDialog.noteGuidelines.pre")}
  285. <a
  286. href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
  287. target="_blank"
  288. rel="noopener noreferrer"
  289. >
  290. {t("publishDialog.noteGuidelines.link")}
  291. </a>
  292. {t("publishDialog.noteGuidelines.post")}
  293. </span>
  294. <div className="publish-library-note">
  295. {t("publishDialog.noteItems")}
  296. </div>
  297. {containsPublishedItems && (
  298. <span className="publish-library-note publish-library-warning">
  299. {t("publishDialog.republishWarning")}
  300. </span>
  301. )}
  302. {renderLibraryItems()}
  303. <div className="publish-library__fields">
  304. <label>
  305. <div>
  306. <span>{t("publishDialog.libraryName")}</span>
  307. <span aria-hidden="true" className="required">
  308. *
  309. </span>
  310. </div>
  311. <input
  312. type="text"
  313. name="name"
  314. required
  315. value={libraryData.name}
  316. onChange={onInputChange}
  317. placeholder={t("publishDialog.placeholder.libraryName")}
  318. />
  319. </label>
  320. <label style={{ alignItems: "flex-start" }}>
  321. <div>
  322. <span>{t("publishDialog.libraryDesc")}</span>
  323. <span aria-hidden="true" className="required">
  324. *
  325. </span>
  326. </div>
  327. <textarea
  328. name="description"
  329. rows={4}
  330. required
  331. value={libraryData.description}
  332. onChange={onInputChange}
  333. placeholder={t("publishDialog.placeholder.libraryDesc")}
  334. />
  335. </label>
  336. <label>
  337. <div>
  338. <span>{t("publishDialog.authorName")}</span>
  339. <span aria-hidden="true" className="required">
  340. *
  341. </span>
  342. </div>
  343. <input
  344. type="text"
  345. name="authorName"
  346. required
  347. value={libraryData.authorName}
  348. onChange={onInputChange}
  349. placeholder={t("publishDialog.placeholder.authorName")}
  350. />
  351. </label>
  352. <label>
  353. <span>{t("publishDialog.githubUsername")}</span>
  354. <input
  355. type="text"
  356. name="githubHandle"
  357. value={libraryData.githubHandle}
  358. onChange={onInputChange}
  359. placeholder={t("publishDialog.placeholder.githubHandle")}
  360. />
  361. </label>
  362. <label>
  363. <span>{t("publishDialog.twitterUsername")}</span>
  364. <input
  365. type="text"
  366. name="twitterHandle"
  367. value={libraryData.twitterHandle}
  368. onChange={onInputChange}
  369. placeholder={t("publishDialog.placeholder.twitterHandle")}
  370. />
  371. </label>
  372. <label>
  373. <span>{t("publishDialog.website")}</span>
  374. <input
  375. type="text"
  376. name="website"
  377. pattern="https?://.+"
  378. title={t("publishDialog.errors.website")}
  379. value={libraryData.website}
  380. onChange={onInputChange}
  381. placeholder={t("publishDialog.placeholder.website")}
  382. />
  383. </label>
  384. <span className="publish-library-note">
  385. {t("publishDialog.noteLicense.pre")}
  386. <a
  387. href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
  388. target="_blank"
  389. rel="noopener noreferrer"
  390. >
  391. {t("publishDialog.noteLicense.link")}
  392. </a>
  393. {t("publishDialog.noteLicense.post")}
  394. </span>
  395. </div>
  396. <div className="publish-library__buttons">
  397. <DialogActionButton
  398. label={t("buttons.cancel")}
  399. onClick={onDialogClose}
  400. data-testid="cancel-clear-canvas-button"
  401. />
  402. <DialogActionButton
  403. type="submit"
  404. label={t("buttons.submit")}
  405. actionType="primary"
  406. isLoading={isSubmitting}
  407. />
  408. </div>
  409. </form>
  410. ) : (
  411. <p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
  412. {t("publishDialog.atleastOneLibItem")}
  413. </p>
  414. )}
  415. </Dialog>
  416. );
  417. };
  418. export default PublishLibrary;