aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/PublishLibrary.tsx
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/PublishLibrary.tsx
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/PublishLibrary.tsx')
-rw-r--r--packages/excalidraw/components/PublishLibrary.tsx540
1 files changed, 540 insertions, 0 deletions
diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx
new file mode 100644
index 0000000..fe68f88
--- /dev/null
+++ b/packages/excalidraw/components/PublishLibrary.tsx
@@ -0,0 +1,540 @@
+import type { ReactNode } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import OpenColor from "open-color";
+
+import { Dialog } from "./Dialog";
+import { t } from "../i18n";
+import Trans from "./Trans";
+
+import type { LibraryItems, LibraryItem, UIAppState } from "../types";
+import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export";
+import {
+ EDITOR_LS_KEYS,
+ EXPORT_DATA_TYPES,
+ EXPORT_SOURCE,
+ MIME_TYPES,
+ VERSIONS,
+} from "../constants";
+import type { ExportedLibraryData } from "../data/types";
+import { canvasToBlob, resizeImageFile } from "../data/blob";
+import { chunk } from "../utils";
+import DialogActionButton from "./DialogActionButton";
+import { CloseIcon } from "./icons";
+import { ToolButton } from "./ToolButton";
+import { EditorLocalStorage } from "../data/EditorLocalStorage";
+
+import "./PublishLibrary.scss";
+
+interface PublishLibraryDataParams {
+ authorName: string;
+ githubHandle: string;
+ name: string;
+ description: string;
+ twitterHandle: string;
+ website: string;
+}
+
+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 SingleLibraryItem = ({
+ libItem,
+ appState,
+ index,
+ onChange,
+ onRemove,
+}: {
+ libItem: LibraryItem;
+ appState: UIAppState;
+ index: number;
+ onChange: (val: string, index: number) => void;
+ onRemove: (id: string) => void;
+}) => {
+ const svgRef = useRef<HTMLDivElement | null>(null);
+ const inputRef = useRef<HTMLInputElement | null>(null);
+
+ useEffect(() => {
+ const node = svgRef.current;
+ if (!node) {
+ return;
+ }
+ (async () => {
+ const svg = await exportToSvg({
+ elements: libItem.elements,
+ appState: {
+ ...appState,
+ viewBackgroundColor: OpenColor.white,
+ exportBackground: true,
+ },
+ files: null,
+ skipInliningFonts: true,
+ });
+ node.innerHTML = svg.outerHTML;
+ })();
+ }, [libItem.elements, appState]);
+
+ return (
+ <div className="single-library-item">
+ {libItem.status === "published" && (
+ <span className="single-library-item-status">
+ {t("labels.statusPublished")}
+ </span>
+ )}
+ <div ref={svgRef} className="single-library-item__svg" />
+ <ToolButton
+ aria-label={t("buttons.remove")}
+ type="button"
+ icon={CloseIcon}
+ className="single-library-item--remove"
+ onClick={onRemove.bind(null, libItem.id)}
+ title={t("buttons.remove")}
+ />
+ <div
+ style={{
+ display: "flex",
+ margin: "0.8rem 0",
+ width: "100%",
+ fontSize: "14px",
+ fontWeight: 500,
+ flexDirection: "column",
+ }}
+ >
+ <label
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ flexDirection: "column",
+ }}
+ >
+ <div style={{ padding: "0.5em 0" }}>
+ <span style={{ fontWeight: 500, color: OpenColor.gray[6] }}>
+ {t("publishDialog.itemName")}
+ </span>
+ <span aria-hidden="true" className="required">
+ *
+ </span>
+ </div>
+ <input
+ type="text"
+ ref={inputRef}
+ style={{ width: "80%", padding: "0.2rem" }}
+ defaultValue={libItem.name}
+ placeholder="Item name"
+ onChange={(event) => {
+ onChange(event.target.value, index);
+ }}
+ />
+ </label>
+ <span className="error">{libItem.error}</span>
+ </div>
+ </div>
+ );
+};
+
+const PublishLibrary = ({
+ onClose,
+ libraryItems,
+ appState,
+ onSuccess,
+ onError,
+ updateItemsInStorage,
+ onRemove,
+}: {
+ onClose: () => void;
+ libraryItems: LibraryItems;
+ appState: UIAppState;
+ 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 = EditorLocalStorage.get<PublishLibraryDataParams>(
+ EDITOR_LS_KEYS.PUBLISH_LIBRARY,
+ );
+ 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(`${import.meta.env.VITE_APP_LIBRARY_BACKEND}/submit`, {
+ method: "post",
+ body: formData,
+ })
+ .then(
+ (response) => {
+ if (response.ok) {
+ return response.json().then(({ url }) => {
+ // flush data from local storage
+ EditorLocalStorage.delete(EDITOR_LS_KEYS.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);
+ EditorLocalStorage.set(EDITOR_LS_KEYS.PUBLISH_LIBRARY, 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">
+ <Trans
+ i18nKey="publishDialog.noteDescription"
+ link={(el) => (
+ <a
+ href="https://libraries.excalidraw.com"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {el}
+ </a>
+ )}
+ />
+ </div>
+ <span className="publish-library-note">
+ <Trans
+ i18nKey="publishDialog.noteGuidelines"
+ link={(el) => (
+ <a
+ href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {el}
+ </a>
+ )}
+ />
+ </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">
+ <Trans
+ i18nKey="publishDialog.noteLicense"
+ link={(el) => (
+ <a
+ href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {el}
+ </a>
+ )}
+ />
+ </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;