import { generateImage } from '@shared/api/DataApi'; import { importImage, importImageServer } from '@shared/api/wp'; import { track } from '@shared/lib/track'; import { useImageGenerationStore } from '@shared/state/generate-images'; import { Button, Modal, Notice, Spinner, TextareaControl, __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption, } from '@wordpress/components'; import { useRef, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { loadProduct, save, saveProduct } from '../../lib/api'; import { invalidateBlockSource } from '../../lib/block-source-cache'; import { useCmdEnterSave } from '../../lib/cmd-enter-save'; import { splice } from '../../lib/dom'; import { friendlyMessage } from '../../lib/errors'; import { QE_MODAL_BODY_OPEN_CLASS } from '../../lib/modal-root'; import { pushUndo } from '../../state/undo'; import { ModalCloseButton } from './ModalCloseButton'; const readImageAttrs = (liveEl) => { const img = liveEl.querySelector('.wp-block-cover__image-background') || liveEl.querySelector('img'); if (!img) return null; const url = img.getAttribute('src') || ''; const alt = img.getAttribute('alt') || ''; let id = null; for (const cls of img.classList) { const m = /^wp-image-(\d+)$/.exec(cls); if (m) { id = Number(m[1]); break; } } return { url, id, alt }; }; export const AiImagePickerModal = ({ selected, field, onAfterSave }) => { const { imageCredits, updateImageCredits, subtractOneCredit, aiImageOptions, setAiImageOption, } = useImageGenerationStore(); const [generating, setGenerating] = useState(false); const [applying, setApplying] = useState(false); const [error, setError] = useState(''); const [preview, setPreview] = useState(null); // { src, id } const abortRef = useRef(null); const noCredits = imageCredits.remaining === 0; const usedCredits = imageCredits.total - imageCredits.remaining; const onGenerate = async (e) => { e?.preventDefault?.(); setError(''); if (noCredits || !aiImageOptions.prompt) return; try { setGenerating(true); subtractOneCredit(); abortRef.current = new AbortController(); const { imageCredits: newCredits, images, id: gid, } = await generateImage(aiImageOptions, abortRef.current.signal); updateImageCredits(newCredits); setPreview({ src: images[0].url, id: gid }); track('ai_image_generated', { size: aiImageOptions.size }); } catch (err) { if (err?.code === 20) return; // aborted if (!err?.imageCredits) { setError( err?.message || __('Image generation failed', 'extendify-local'), ); updateImageCredits({ remaining: imageCredits.remaining }); track('ai_image_failed'); return; } updateImageCredits(err.imageCredits); setError(err.message); track('ai_image_failed', { reason: 'no_credits' }); } finally { setGenerating(false); } }; const onUse = async () => { if (!preview?.src || applying) return; setError(''); setApplying(true); try { let attachment; try { attachment = await importImage(preview.src, { alt: aiImageOptions.prompt, filename: 'ai-image.jpg', caption: '', }); } catch (_e) { attachment = await importImageServer(preview.src, { alt: aiImageOptions.prompt, caption: '', }); } const mediaId = attachment?.id; if (!mediaId) throw new Error('No media id returned'); // Product image: write the featured image via WC's save // endpoint and reload (the same image cascades to many // surfaces — splice can't reach all of them). if (selected.source?.kind === 'product') { let beforeImageId = 0; try { const cur = await loadProduct(selected.source.id); beforeImageId = Number(cur?.image_id) || 0; } catch (_) { // non-fatal — undo entry just won't have a before-state } await saveProduct({ productId: selected.source.id, field: 'image', value: mediaId, }); if (beforeImageId && beforeImageId !== mediaId) { pushUndo({ kind: 'product-image', productReplay: true, productId: selected.source.id, field: 'image', beforeValue: beforeImageId, }); } track('image_replaced', { source: 'ai', kind: 'product' }); onAfterSave(true); return; } const before = readImageAttrs(selected.mediaEl ?? selected.el); const res = await save({ source: selected.source, blockId: selected.blockId, blockType: selected.blockName ?? selected.blockType, patches: [ { fieldKey: field, value: { url: attachment.url || attachment.source_url, id: mediaId, alt: aiImageOptions.prompt || '', }, }, ], }); if (!res.rendered) throw new Error('No rendered HTML'); const newEl = splice(selected.el, res.rendered); if (!newEl) throw new Error('Splice failed'); invalidateBlockSource(selected.source, selected.blockId); if (before) { pushUndo({ kind: 'image', source: selected.source, blockId: selected.blockId, blockType: selected.blockName ?? selected.blockType, patches: [{ fieldKey: field, value: before }], }); } track('image_replaced', { source: 'ai' }); onAfterSave(true); } catch (err) { track('save_failed', { kind: 'image', source: 'ai' }); setError(friendlyMessage(err)); setApplying(false); } }; const onClear = () => { setPreview(null); setError(''); }; const onClose = () => { abortRef.current?.abort(); onAfterSave(false); }; useCmdEnterSave( onGenerate, !preview?.src && !generating && !!aiImageOptions.prompt && !noCredits, ); return ( } className="extendify-quick-edit-modal extendify-quick-edit-ai-image" overlayClassName="extendify-quick-edit" bodyOpenClassName={QE_MODAL_BODY_OPEN_CLASS} size="medium" > {error ? ( {error} ) : null} {preview?.src ? (
{aiImageOptions.prompt}
) : (
setAiImageOption('prompt', v)} rows={4} disabled={generating} __nextHasNoMarginBottom /> setAiImageOption('size', v)} __nextHasNoMarginBottom > {generating ? ( // biome-ignore lint/a11y/useSemanticElements: deliberate live region; changes display + semantics
{__('Generating image…', 'extendify-local')}
) : null}
{sprintf( // translators: %1$d is the number of credits used, %2$d is the total credits available. __('%1$d of %2$d credits used', 'extendify-local'), usedCredits, imageCredits.total, )}
)}
{preview?.src ? ( <> ) : ( <> )}
); };