| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458 |
- import { ReactNode, useCallback, useEffect, useState } from "react";
- import OpenColor from "open-color";
- import { Dialog } from "./Dialog";
- import { t } from "../i18n";
- import { AppState, LibraryItems, LibraryItem } from "../types";
- import { exportToCanvas } from "../packages/utils";
- import {
- EXPORT_DATA_TYPES,
- EXPORT_SOURCE,
- MIME_TYPES,
- VERSIONS,
- } from "../constants";
- import { ExportedLibraryData } from "../data/types";
- import "./PublishLibrary.scss";
- import SingleLibraryItem from "./SingleLibraryItem";
- import { canvasToBlob, resizeImageFile } from "../data/blob";
- import { chunk } from "../utils";
- import DialogActionButton from "./DialogActionButton";
- interface PublishLibraryDataParams {
- authorName: string;
- githubHandle: string;
- name: string;
- description: string;
- twitterHandle: string;
- website: string;
- }
- const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
- const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
- try {
- localStorage.setItem(
- LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
- JSON.stringify(data),
- );
- } catch (error: any) {
- // Unable to access window.localStorage
- console.error(error);
- }
- };
- const importPublishLibDataFromStorage = () => {
- try {
- const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
- if (data) {
- return JSON.parse(data);
- }
- } catch (error: any) {
- // Unable to access localStorage
- console.error(error);
- }
- return null;
- };
- const generatePreviewImage = async (libraryItems: LibraryItems) => {
- const MAX_ITEMS_PER_ROW = 6;
- const BOX_SIZE = 128;
- const BOX_PADDING = Math.round(BOX_SIZE / 16);
- const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
- const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
- const canvas = document.createElement("canvas");
- canvas.width =
- rows[0].length * BOX_SIZE +
- (rows[0].length + 1) * (BOX_PADDING * 2) -
- BOX_PADDING * 2;
- canvas.height =
- rows.length * BOX_SIZE +
- (rows.length + 1) * (BOX_PADDING * 2) -
- BOX_PADDING * 2;
- const ctx = canvas.getContext("2d")!;
- ctx.fillStyle = OpenColor.white;
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- // draw items
- // ---------------------------------------------------------------------------
- for (const [index, item] of libraryItems.entries()) {
- const itemCanvas = await exportToCanvas({
- elements: item.elements,
- files: null,
- maxWidthOrHeight: BOX_SIZE,
- });
- const { width, height } = itemCanvas;
- // draw item
- // -------------------------------------------------------------------------
- const rowOffset =
- Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
- const colOffset =
- (index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
- ctx.drawImage(
- itemCanvas,
- colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
- rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
- );
- // draw item border
- // -------------------------------------------------------------------------
- ctx.lineWidth = BORDER_WIDTH;
- ctx.strokeStyle = OpenColor.gray[4];
- ctx.strokeRect(
- colOffset + BOX_PADDING / 2,
- rowOffset + BOX_PADDING / 2,
- BOX_SIZE + BOX_PADDING,
- BOX_SIZE + BOX_PADDING,
- );
- }
- return await resizeImageFile(
- new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
- {
- outputType: MIME_TYPES.jpg,
- maxWidthOrHeight: 5000,
- },
- );
- };
- const PublishLibrary = ({
- onClose,
- libraryItems,
- appState,
- onSuccess,
- onError,
- updateItemsInStorage,
- onRemove,
- }: {
- onClose: () => void;
- libraryItems: LibraryItems;
- appState: AppState;
- onSuccess: (data: {
- url: string;
- authorName: string;
- items: LibraryItems;
- }) => void;
- onError: (error: Error) => void;
- updateItemsInStorage: (items: LibraryItems) => void;
- onRemove: (id: string) => void;
- }) => {
- const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
- authorName: "",
- githubHandle: "",
- name: "",
- description: "",
- twitterHandle: "",
- website: "",
- });
- const [isSubmitting, setIsSubmitting] = useState(false);
- useEffect(() => {
- const data = importPublishLibDataFromStorage();
- if (data) {
- setLibraryData(data);
- }
- }, []);
- const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
- libraryItems.slice(),
- );
- useEffect(() => {
- setClonedLibItems(libraryItems.slice());
- }, [libraryItems]);
- const onInputChange = (event: any) => {
- setLibraryData({
- ...libraryData,
- [event.target.name]: event.target.value,
- });
- };
- const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
- event.preventDefault();
- setIsSubmitting(true);
- const erroredLibItems: LibraryItem[] = [];
- let isError = false;
- clonedLibItems.forEach((libItem) => {
- let error = "";
- if (!libItem.name) {
- error = t("publishDialog.errors.required");
- isError = true;
- }
- erroredLibItems.push({ ...libItem, error });
- });
- if (isError) {
- setClonedLibItems(erroredLibItems);
- setIsSubmitting(false);
- return;
- }
- const previewImage = await generatePreviewImage(clonedLibItems);
- const libContent: ExportedLibraryData = {
- type: EXPORT_DATA_TYPES.excalidrawLibrary,
- version: VERSIONS.excalidrawLibrary,
- source: EXPORT_SOURCE,
- libraryItems: clonedLibItems,
- };
- const content = JSON.stringify(libContent, null, 2);
- const lib = new Blob([content], { type: "application/json" });
- const formData = new FormData();
- formData.append("excalidrawLib", lib);
- formData.append("previewImage", previewImage);
- formData.append("previewImageType", previewImage.type);
- formData.append("title", libraryData.name);
- formData.append("authorName", libraryData.authorName);
- formData.append("githubHandle", libraryData.githubHandle);
- formData.append("name", libraryData.name);
- formData.append("description", libraryData.description);
- formData.append("twitterHandle", libraryData.twitterHandle);
- formData.append("website", libraryData.website);
- fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
- method: "post",
- body: formData,
- })
- .then(
- (response) => {
- if (response.ok) {
- return response.json().then(({ url }) => {
- // flush data from local storage
- localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
- onSuccess({
- url,
- authorName: libraryData.authorName,
- items: clonedLibItems,
- });
- });
- }
- return response
- .json()
- .catch(() => {
- throw new Error(response.statusText || "something went wrong");
- })
- .then((error) => {
- throw new Error(
- error.message || response.statusText || "something went wrong",
- );
- });
- },
- (err) => {
- console.error(err);
- onError(err);
- setIsSubmitting(false);
- },
- )
- .catch((err) => {
- console.error(err);
- onError(err);
- setIsSubmitting(false);
- });
- };
- const renderLibraryItems = () => {
- const items: ReactNode[] = [];
- clonedLibItems.forEach((libItem, index) => {
- items.push(
- <div className="single-library-item-wrapper" key={index}>
- <SingleLibraryItem
- libItem={libItem}
- appState={appState}
- index={index}
- onChange={(val, index) => {
- const items = clonedLibItems.slice();
- items[index].name = val;
- setClonedLibItems(items);
- }}
- onRemove={onRemove}
- />
- </div>,
- );
- });
- return <div className="selected-library-items">{items}</div>;
- };
- const onDialogClose = useCallback(() => {
- updateItemsInStorage(clonedLibItems);
- savePublishLibDataToStorage(libraryData);
- onClose();
- }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
- const shouldRenderForm = !!libraryItems.length;
- const containsPublishedItems = libraryItems.some(
- (item) => item.status === "published",
- );
- return (
- <Dialog
- onCloseRequest={onDialogClose}
- title={t("publishDialog.title")}
- className="publish-library"
- >
- {shouldRenderForm ? (
- <form onSubmit={onSubmit}>
- <div className="publish-library-note">
- {t("publishDialog.noteDescription.pre")}
- <a
- href="https://libraries.excalidraw.com"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t("publishDialog.noteDescription.link")}
- </a>{" "}
- {t("publishDialog.noteDescription.post")}
- </div>
- <span className="publish-library-note">
- {t("publishDialog.noteGuidelines.pre")}
- <a
- href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t("publishDialog.noteGuidelines.link")}
- </a>
- {t("publishDialog.noteGuidelines.post")}
- </span>
- <div className="publish-library-note">
- {t("publishDialog.noteItems")}
- </div>
- {containsPublishedItems && (
- <span className="publish-library-note publish-library-warning">
- {t("publishDialog.republishWarning")}
- </span>
- )}
- {renderLibraryItems()}
- <div className="publish-library__fields">
- <label>
- <div>
- <span>{t("publishDialog.libraryName")}</span>
- <span aria-hidden="true" className="required">
- *
- </span>
- </div>
- <input
- type="text"
- name="name"
- required
- value={libraryData.name}
- onChange={onInputChange}
- placeholder={t("publishDialog.placeholder.libraryName")}
- />
- </label>
- <label style={{ alignItems: "flex-start" }}>
- <div>
- <span>{t("publishDialog.libraryDesc")}</span>
- <span aria-hidden="true" className="required">
- *
- </span>
- </div>
- <textarea
- name="description"
- rows={4}
- required
- value={libraryData.description}
- onChange={onInputChange}
- placeholder={t("publishDialog.placeholder.libraryDesc")}
- />
- </label>
- <label>
- <div>
- <span>{t("publishDialog.authorName")}</span>
- <span aria-hidden="true" className="required">
- *
- </span>
- </div>
- <input
- type="text"
- name="authorName"
- required
- value={libraryData.authorName}
- onChange={onInputChange}
- placeholder={t("publishDialog.placeholder.authorName")}
- />
- </label>
- <label>
- <span>{t("publishDialog.githubUsername")}</span>
- <input
- type="text"
- name="githubHandle"
- value={libraryData.githubHandle}
- onChange={onInputChange}
- placeholder={t("publishDialog.placeholder.githubHandle")}
- />
- </label>
- <label>
- <span>{t("publishDialog.twitterUsername")}</span>
- <input
- type="text"
- name="twitterHandle"
- value={libraryData.twitterHandle}
- onChange={onInputChange}
- placeholder={t("publishDialog.placeholder.twitterHandle")}
- />
- </label>
- <label>
- <span>{t("publishDialog.website")}</span>
- <input
- type="text"
- name="website"
- pattern="https?://.+"
- title={t("publishDialog.errors.website")}
- value={libraryData.website}
- onChange={onInputChange}
- placeholder={t("publishDialog.placeholder.website")}
- />
- </label>
- <span className="publish-library-note">
- {t("publishDialog.noteLicense.pre")}
- <a
- href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
- target="_blank"
- rel="noopener noreferrer"
- >
- {t("publishDialog.noteLicense.link")}
- </a>
- {t("publishDialog.noteLicense.post")}
- </span>
- </div>
- <div className="publish-library__buttons">
- <DialogActionButton
- label={t("buttons.cancel")}
- onClick={onDialogClose}
- data-testid="cancel-clear-canvas-button"
- />
- <DialogActionButton
- type="submit"
- label={t("buttons.submit")}
- actionType="primary"
- isLoading={isSubmitting}
- />
- </div>
- </form>
- ) : (
- <p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
- {t("publishDialog.atleastOneLibItem")}
- </p>
- )}
- </Dialog>
- );
- };
- export default PublishLibrary;
|