From 6ec259a0e71174651bae95d4628138bf6fd68742 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 15 Mar 2026 16:19:35 -0400 Subject: refactor: packages/ --- packages/excalidraw/components/PublishLibrary.tsx | 540 ++++++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 packages/excalidraw/components/PublishLibrary.tsx (limited to 'packages/excalidraw/components/PublishLibrary.tsx') 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(null); + const inputRef = useRef(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 ( +
+ {libItem.status === "published" && ( + + {t("labels.statusPublished")} + + )} +
+ +
+ + {libItem.error} +
+
+ ); +}; + +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({ + authorName: "", + githubHandle: "", + name: "", + description: "", + twitterHandle: "", + website: "", + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + const data = EditorLocalStorage.get( + EDITOR_LS_KEYS.PUBLISH_LIBRARY, + ); + if (data) { + setLibraryData(data); + } + }, []); + + const [clonedLibItems, setClonedLibItems] = useState( + 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) => { + 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( +
+ { + const items = clonedLibItems.slice(); + items[index].name = val; + setClonedLibItems(items); + }} + onRemove={onRemove} + /> +
, + ); + }); + return
{items}
; + }; + + 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 ( + + {shouldRenderForm ? ( +
+
+ ( + + {el} + + )} + /> +
+ + ( + + {el} + + )} + /> + + +
+ {t("publishDialog.noteItems")} +
+ {containsPublishedItems && ( + + {t("publishDialog.republishWarning")} + + )} + {renderLibraryItems()} +
+ +