import { useStableCallback } from "@prismicio/editor-support/React"; import { BlankSlate, BlankSlateIcon, Box, Button, Dialog, DialogActionButton, DialogActions, DialogCancelButton, DialogContent, DialogDescription, DialogHeader, FileDropZone, FileUploadButton, ProgressCircle, ScrollArea, Text, } from "@prismicio/editor-ui"; import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; import { isUnauthenticatedError } from "@slicemachine/manager/client"; import { useRouter } from "next/router"; import { useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { toast } from "react-toastify"; import { z } from "zod"; import { getState, telemetry } from "@/apiClient"; import { addAiFeedback } from "@/features/aiFeedback"; import { useOnboarding } from "@/features/onboarding/useOnboarding"; import { useAutoSync } from "@/features/sync/AutoSyncProvider"; import { useExperimentVariant } from "@/hooks/useExperimentVariant"; import { FigmaIcon } from "@/icons/FigmaIcon"; import { managerClient } from "@/managerClient"; import useSliceMachineActions from "@/modules/useSliceMachineActions"; import { Slice, SliceCard } from "./SliceCard"; const IMAGE_UPLOAD_LIMIT = 10; interface CreateSliceFromImageModalProps { open: boolean; location: "custom_type" | "page_type" | "slices"; onSuccess: (args: { slices: SharedSlice[]; library: string }) => void; onClose: () => void; } const clipboardDataSchema = z.object({ __type: z.literal("figma-to-prismic/clipboard-data"), name: z.string(), image: z.string().startsWith("data:image/"), }); export function CreateSliceFromImageModal( props: CreateSliceFromImageModalProps, ) { const { open, location, onSuccess, onClose } = props; const router = useRouter(); const [slices, setSlices] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [showCancelConfirmation, setShowCancelConfirmation] = useState(false); const { syncChanges } = useAutoSync(); const { createSliceSuccess, openLoginModal } = useSliceMachineActions(); const { completeStep } = useOnboarding(); const existingSlices = useExistingSlices({ open }); const isFigmaEnabled = useIsFigmaEnabled(); const { libraryID, isLoading: isLoadingLibraryID } = useLibraryID(); const stableCancelGeneratingRequests = useStableCallback( cancelGeneratingRequests, ); /** * Keeps track of the current instance id. * When the modal is closed, the id is reset. */ const id = useRef(crypto.randomUUID()); useHotkeys( ["meta+v", "ctrl+v"], (event) => { event.preventDefault(); void onPaste({ isShortcut: true }); }, { enabled: open && isFigmaEnabled }, ); useEffect(() => { if (!slices.some((slice) => slice.status === "generating")) return; const onBeforeUnload = (event: BeforeUnloadEvent) => { stableCancelGeneratingRequests(); const message = "Your current generating slices will be cancelled."; event.returnValue = message; return message; }; router.events.on("routeChangeStart", stableCancelGeneratingRequests); window.addEventListener("beforeunload", onBeforeUnload); return () => { router.events.off("routeChangeStart", stableCancelGeneratingRequests); window.removeEventListener("beforeunload", onBeforeUnload); }; }, [slices, router.events, stableCancelGeneratingRequests]); const setSlice = (args: { index: number; slice: (prevSlice: Slice) => Slice; }) => { const { index, slice } = args; setSlices((slices) => slices.map((s, i) => (i === index ? slice(s) : s))); }; const onImagesSelected = (images: File[]) => { if (hasTriggeredGeneration) return; if (images.length > IMAGE_UPLOAD_LIMIT) { toast.error( `You can only upload ${IMAGE_UPLOAD_LIMIT} images at a time.`, ); return; } const startIndex = slices.length; setSlices((prevSlices) => [ ...prevSlices, ...images.map( (image): Slice => ({ source: "upload", status: "uploading", image, }), ), ]); images.forEach((image, relativeIndex) => { const index = startIndex + relativeIndex; void uploadImage({ index, image, source: "upload" }); }); }; const failAndPromptLogin = () => { toast.error("Please log in and try again."); closeModal(); openLoginModal(); }; const uploadImage = async (args: { index: number; image: File; source: "figma" | "upload"; }) => { const { index, image, source } = args; const currentId = id.current; setSlice({ index, slice: (prevSlice) => ({ ...prevSlice, status: "uploading", image, source, }), }); try { const imageUrl = await getImageUrl({ image }); if (currentId !== id.current) return; setSlice({ index, slice: (prevSlice) => ({ ...prevSlice, status: "pending", thumbnailUrl: imageUrl, }), }); } catch (error) { if (currentId !== id.current) return; setSlice({ index, slice: (prevSlice) => ({ ...prevSlice, status: "uploadError", onRetry: () => void uploadImage({ index, image, source }), }), }); if (isUnauthenticatedError(error)) { failAndPromptLogin(); return; } } }; const inferSlice = async (args: { index: number; imageUrl: string; source: "figma" | "upload"; }) => { if (libraryID === undefined) return; const { index, imageUrl, source } = args; let currentId = id.current; const requestId = crypto.randomUUID(); setSlice({ index, slice: (prevSlice) => ({ ...prevSlice, status: "generating", thumbnailUrl: imageUrl, requestId, }), }); try { const inferResult = await managerClient.customTypes.inferSlice({ source, libraryID, imageUrl, requestId, }); if (currentId !== id.current) return; const model = sliceWithoutConflicts({ existingSlices: existingSlices.current, newSlices: slices, slice: inferResult.slice, }); setSlices((prevSlices) => { return prevSlices.map((prevSlice, i) => { if (i !== index) return prevSlice; return { ...prevSlice, status: "success", thumbnailUrl: imageUrl, model, langSmithUrl: inferResult.langSmithUrl, }; }); }); if (source === "upload") { currentId = id.current; const currentSlice = slices[index]; const { errors } = await managerClient.slices.createSlice({ libraryID, model: model, }); if (errors.length) { throw new Error(`Failed to create slice ${model.id}.`); } await managerClient.slices.updateSliceScreenshot({ libraryID, sliceID: model.id, variationID: model.variations[0].id, data: currentSlice.image, }); if (currentId !== id.current) return; } void completeStep("createSlice"); void telemetry.track({ event: "slice:created", id: model.id, name: model.name, library: libraryID, location, ...(source === "figma" ? { mode: "figma-to-slice" } : { mode: "ai", langSmithUrl: inferResult.langSmithUrl }), }); void telemetry.track({ event: "slice-generation:ended", error: false, source, }); addAiFeedback({ type: "model", library: libraryID, sliceId: model.id, variationId: model.variations[0].id, langSmithUrl: inferResult.langSmithUrl, }); } catch (error) { if (currentId !== id.current) return; setSlice({ index, slice: (prevSlice) => ({ ...prevSlice, status: error instanceof Error && error.name === "AbortError" ? "cancelled" : "generateError", thumbnailUrl: imageUrl, onRetry: () => { void inferSlice({ index, imageUrl, source }); }, }), }); if (isUnauthenticatedError(error)) { failAndPromptLogin(); return; } void telemetry.track({ event: "slice-generation:ended", error: true, source, }); } }; const generatePendingSlices = () => { if (libraryID === undefined) return; slices.forEach((slice, index) => { if (slice.status === "pending") { void inferSlice({ index, imageUrl: slice.thumbnailUrl, source: slice.source, }); } }); }; const totals = slices.reduce( (result, slice) => { if (slice.status === "generating") { result.generating++; } else if (slice.status === "uploading") { result.uploading++; } else if (slice.status === "pending") { result.pending++; } else if (slice.status === "success") { result.completed++; } result.loading = result.generating + result.uploading; /** Total count for the generate button. * Avoids resetting to zero when switching status for better UX. */ result.generate = result.loading + result.pending; return result; }, { generating: 0, uploading: 0, pending: 0, completed: 0, loading: 0, generate: 0, }, ); const hasTriggeredGeneration = totals.generating > 0 || totals.completed > 0; const closeModal = () => { if (totals.loading > 0) return; onClose(); id.current = crypto.randomUUID(); setTimeout(() => setSlices([]), 250); // wait for the modal fade animation }; const onSubmit = async () => { if (libraryID === undefined) return; try { setIsSubmitting(true); const serverState = await getState(); createSliceSuccess(serverState.libraries); syncChanges(); onSuccess({ slices: slices.flatMap((slice) => slice.status === "success" ? slice.model : [], ), library: libraryID, }); closeModal(); } finally { setIsSubmitting(false); } }; const onPaste = async (args?: { isShortcut?: boolean }) => { const { isShortcut = false } = args ?? {}; if ( !open || !isFigmaEnabled || // For now we only support one Figma slice at a time slices.some((slice) => slice.source === "figma") ) { return; } // Don't allow pasting while uploads or generation are in progress const isLoading = slices.some( (slice) => slice.status === "uploading" || slice.status === "generating", ); if (isLoading) return; const supportsClipboardRead = typeof navigator.clipboard?.read === "function"; if (!supportsClipboardRead) { toast.error("Clipboard paste is not supported in this browser."); return; } try { const clipboardItems = await navigator.clipboard.read(); if (clipboardItems.length === 0) { toast.error("No data found in clipboard."); return; } let imageName = "pasted-image.png"; let imageBlob: Blob | null = null; let success = false; // Method 1: Try to extract image from clipboard image/png blob (preferred) for (const item of clipboardItems) { const imageType = item.types.find((type) => type.startsWith("image/")); if (imageType !== undefined) { imageBlob = await item.getType(imageType); break; } } // Method 2: Read JSON from text/plain to get metadata and base64 image as fallback for (const item of clipboardItems) { if (item.types.includes("text/plain")) { try { const textBlob = await item.getType("text/plain"); const text = await textBlob.text(); const result = clipboardDataSchema.safeParse(JSON.parse(text)); if (result.success) { success = true; const data = result.data; imageName = `${data.name}.png`; // Use base64 image as fallback if no blob was found if (!imageBlob) { const response = await fetch(data.image); imageBlob = await response.blob(); } } else { console.warn("Clipboard data validation failed:", result.error); } } catch (error) { console.warn("Failed to parse JSON from clipboard:", error); // Continue - we may still have imageBlob from Method 1 } } } if (!imageBlob) { if (success) { toast.error( "Could not extract Figma data from clipboard. Please try copying again using the Prismic Figma plugin.", ); } else { toast.error( "No Figma data found in clipboard. Make sure you've copied a design using the Prismic Figma plugin.", ); } return; } // Check if we're at the limit const currentSliceCount = slices.length; if (currentSliceCount >= IMAGE_UPLOAD_LIMIT) { toast.error( `You can only upload ${IMAGE_UPLOAD_LIMIT} images at a time.`, ); return; } // Create File object from blob and append to existing slices const image = new File([imageBlob], imageName, { type: imageBlob.type, }); const newIndex = currentSliceCount; // Append new slice to existing ones setSlices((prevSlices) => [ ...prevSlices, { source: "figma", status: "uploading", image, }, ]); // Start uploading the new image void uploadImage({ index: newIndex, image, source: "figma" }); void telemetry.track({ event: "slice-generation:pasted-from-figma", source: isShortcut ? "shortcut" : "button", }); toast.success(`Pasted ${imageName}${success ? " from Figma" : ""}`); } catch (error) { console.error("Failed to paste from clipboard:", error); toast.error( "Failed to paste from clipboard. Please check browser permissions and try again.", ); } }; function cancelGeneratingRequests() { const cancelableIds = slices.flatMap((slice) => { return slice.status === "generating" ? [slice.requestId] : []; }); if (cancelableIds.length === 0) return; cancelableIds.forEach((requestId) => { void managerClient.customTypes.cancelInferSlice({ requestId }); }); } const onCancelConfirm = () => { setShowCancelConfirmation(false); cancelGeneratingRequests(); }; return ( !open && closeModal()}> {!isLoadingLibraryID ? ( <> {slices.length === 0 ? ( {isFigmaEnabled && ( Want to work faster? Copy frames from Figma with the Slice Machine plugin and paste them here. )} } > ) : ( <> Design Add images {slices.map((slice, index) => ( ))} )} {totals.generating > 0 ? ( setShowCancelConfirmation(true)} size="medium" sx={{ marginRight: 8 }} invisible > Cancel ) : ( closeModal()} size="medium" sx={{ marginRight: 8 }} invisible > Close )} {totals.completed === 0 || totals.loading > 0 ? ( 0 || totals.pending === 0 } loading={totals.loading > 0} size="medium" > Generate {totals.generate > 0 ? `(${totals.generate}) ` : ""} {totals.generate === 1 ? "Slice" : "Slices"} ) : ( void onSubmit()} loading={isSubmitting} size="medium" > {getSubmitButtonLabel(location, totals.completed)} )} ) : ( )} Are you sure you want to cancel the generation for all slices? setShowCancelConfirmation(false)} size="small" > Keep generating Confirm ); } function UploadBlankSlate(props: { droppingFiles?: boolean; onFilesSelected: (files: File[]) => void; onPaste: () => void | Promise; }) { const { droppingFiles = false, onFilesSelected, onPaste } = props; const isFigmaEnabled = useIsFigmaEnabled(); return ( {isFigmaEnabled ? ( <> Generate slices from your designs Upload your design images or paste them directly from Figma. ) : ( <> Upload your design images. Once uploaded, you can generate slices automatically using AI. )} {isFigmaEnabled ? ( <> Add images ) : ( Add images )} ); } async function getImageUrl({ image }: { image: File }) { const repository = await managerClient.project.getResolvedRepositoryName(); // ACL provider only allows key prefixes starting with "/shared-slices/" const keyPrefix = [ repository, "shared-slices", "prismic-inferred-slices", crypto.randomUUID(), ].join("/"); await managerClient.screenshots.initS3ACL(); const { url } = await managerClient.screenshots.uploadScreenshot({ keyPrefix, data: image, }); return url; } /** * Keeps track of the existing slices in the project. * Re-fetches them when the modal is opened. */ function useExistingSlices({ open }: { open: boolean }) { const ref = useRef([]); useEffect(() => { if (!open) return; ref.current = []; managerClient.slices .readAllSlices() .then((slices) => { ref.current = slices.models.map(({ model }) => model); }) .catch(() => null); }, [open]); return ref; } /** * If needed, assigns new ids and names to avoid conflicts with existing slices. * Names are compared case-insensitively to avoid conflicts * between folder names with different casing. */ function sliceWithoutConflicts({ existingSlices, newSlices, slice, }: { existingSlices: SharedSlice[]; newSlices: Slice[]; slice: SharedSlice; }): SharedSlice { const existingIds = new Set(); const existingNames = new Set(); for (const { id, name } of existingSlices) { existingIds.add(id); existingNames.add(name.toLowerCase()); } for (const slice of newSlices) { if (slice.status !== "success") continue; existingIds.add(slice.model.id); existingNames.add(slice.model.name.toLowerCase()); } let id = slice.id; let counter = 2; while (existingIds.has(id)) { id = `${slice.id}_${counter}`; counter++; } let name = slice.name; counter = 2; while (existingNames.has(name.toLowerCase())) { name = `${slice.name}${counter}`; counter++; } return { ...slice, id, name, }; } const getSubmitButtonLabel = ( location: "custom_type" | "page_type" | "slices", completedSliceCount: number, ) => { switch (location) { case "custom_type": return `Add to type (${completedSliceCount})`; case "page_type": return `Add to page (${completedSliceCount})`; case "slices": return "Done"; } }; function useIsFigmaEnabled() { const experiment = useExperimentVariant("llm-proxy-access"); return experiment?.value === "on"; } function useLibraryID() { const [libraryID, setLibraryID] = useState(); useEffect(() => { managerClient.project .getSliceMachineConfig() .then((smConfig) => { const libraryID = smConfig?.libraries?.[0]; if (libraryID === undefined) { throw new Error("No library found in the config."); } setLibraryID(libraryID); }) .catch(() => { throw new Error("Could not get library ID from the config."); }); }, []); return { libraryID, isLoading: libraryID === undefined }; }