PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / QuickEdit / components / modals / AiImagePickerModal.jsx

AiImagePickerModal.jsx in Extendify 3.1.0, at src/QuickEdit/components/modals/AiImagePickerModal.jsx

315 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { generateImage } from '@shared/api/DataApi';
2 import { importImage, importImageServer } from '@shared/api/wp';
3 import { useImageGenerationStore } from '@shared/state/generate-images';
4 import {
5 Button,
6 Modal,
7 Notice,
8 Spinner,
9 TextareaControl,
10 __experimentalToggleGroupControl as ToggleGroupControl,
11 __experimentalToggleGroupControlOption as ToggleGroupControlOption,
12 } from '@wordpress/components';
13 import { useRef, useState } from '@wordpress/element';
14 import { __, sprintf } from '@wordpress/i18n';
15 import { loadProduct, save, saveProduct } from '../../lib/api';
16 import { invalidateBlockSource } from '../../lib/block-source-cache';
17 import { useCmdEnterSave } from '../../lib/cmd-enter-save';
18 import { splice } from '../../lib/dom';
19 import { friendlyMessage } from '../../lib/errors';
20 import { track } from '../../lib/insights';
21 import { QE_MODAL_BODY_OPEN_CLASS } from '../../lib/modal-root';
22 import { pushUndo } from '../../state/undo';
23 import { ModalCloseButton } from './ModalCloseButton';
24
25 const readImageAttrs = (liveEl) => {
26 const img =
27 liveEl.querySelector('.wp-block-cover__image-background') ||
28 liveEl.querySelector('img');
29 if (!img) return null;
30 const url = img.getAttribute('src') || '';
31 const alt = img.getAttribute('alt') || '';
32 let id = null;
33 for (const cls of img.classList) {
34 const m = /^wp-image-(\d+)$/.exec(cls);
35 if (m) {
36 id = Number(m[1]);
37 break;
38 }
39 }
40 return { url, id, alt };
41 };
42
43 export const AiImagePickerModal = ({ selected, field, onAfterSave }) => {
44 const {
45 imageCredits,
46 updateImageCredits,
47 subtractOneCredit,
48 aiImageOptions,
49 setAiImageOption,
50 } = useImageGenerationStore();
51 const [generating, setGenerating] = useState(false);
52 const [applying, setApplying] = useState(false);
53 const [error, setError] = useState('');
54 const [preview, setPreview] = useState(null); // { src, id }
55 const abortRef = useRef(null);
56
57 const noCredits = imageCredits.remaining === 0;
58 const usedCredits = imageCredits.total - imageCredits.remaining;
59
60 const onGenerate = async (e) => {
61 e?.preventDefault?.();
62 setError('');
63 if (noCredits || !aiImageOptions.prompt) return;
64 try {
65 setGenerating(true);
66 subtractOneCredit();
67 abortRef.current = new AbortController();
68 const {
69 imageCredits: newCredits,
70 images,
71 id: gid,
72 } = await generateImage(aiImageOptions, abortRef.current.signal);
73 updateImageCredits(newCredits);
74 setPreview({ src: images[0].url, id: gid });
75 track('ai_image_generated', { size: aiImageOptions.size });
76 } catch (err) {
77 if (err?.code === 20) return; // aborted
78 if (!err?.imageCredits) {
79 setError(
80 err?.message || __('Image generation failed', 'extendify-local'),
81 );
82 updateImageCredits({ remaining: imageCredits.remaining });
83 track('ai_image_failed');
84 return;
85 }
86 updateImageCredits(err.imageCredits);
87 setError(err.message);
88 track('ai_image_failed', { reason: 'no_credits' });
89 } finally {
90 setGenerating(false);
91 }
92 };
93
94 const onUse = async () => {
95 if (!preview?.src || applying) return;
96 setError('');
97 setApplying(true);
98 try {
99 let attachment;
100 try {
101 attachment = await importImage(preview.src, {
102 alt: aiImageOptions.prompt,
103 filename: 'ai-image.jpg',
104 caption: '',
105 });
106 } catch (_e) {
107 attachment = await importImageServer(preview.src, {
108 alt: aiImageOptions.prompt,
109 caption: '',
110 });
111 }
112 const mediaId = attachment?.id;
113 if (!mediaId) throw new Error('No media id returned');
114
115 // Product image: write the featured image via WC's save
116 // endpoint and reload (the same image cascades to many
117 // surfaces — splice can't reach all of them).
118 if (selected.source?.kind === 'product') {
119 let beforeImageId = 0;
120 try {
121 const cur = await loadProduct(selected.source.id);
122 beforeImageId = Number(cur?.image_id) || 0;
123 } catch (_) {
124 // non-fatal — undo entry just won't have a before-state
125 }
126 await saveProduct({
127 productId: selected.source.id,
128 field: 'image',
129 value: mediaId,
130 });
131 if (beforeImageId && beforeImageId !== mediaId) {
132 pushUndo({
133 kind: 'product-image',
134 productReplay: true,
135 productId: selected.source.id,
136 field: 'image',
137 beforeValue: beforeImageId,
138 });
139 }
140 track('image_replaced', { source: 'ai', kind: 'product' });
141 onAfterSave(true);
142 return;
143 }
144
145 const before = readImageAttrs(selected.mediaEl ?? selected.el);
146 const res = await save({
147 source: selected.source,
148 blockId: selected.blockId,
149 blockType: selected.blockName ?? selected.blockType,
150 patches: [
151 {
152 fieldKey: field,
153 value: {
154 url: attachment.url || attachment.source_url,
155 id: mediaId,
156 alt: aiImageOptions.prompt || '',
157 },
158 },
159 ],
160 });
161 if (!res.rendered) throw new Error('No rendered HTML');
162 const newEl = splice(selected.el, res.rendered);
163 if (!newEl) throw new Error('Splice failed');
164 invalidateBlockSource(selected.source, selected.blockId);
165 if (before) {
166 pushUndo({
167 kind: 'image',
168 source: selected.source,
169 blockId: selected.blockId,
170 blockType: selected.blockName ?? selected.blockType,
171 patches: [{ fieldKey: field, value: before }],
172 });
173 }
174 track('image_replaced', { source: 'ai' });
175 onAfterSave(true);
176 } catch (err) {
177 track('save_failed', { kind: 'image', source: 'ai' });
178 setError(friendlyMessage(err));
179 setApplying(false);
180 }
181 };
182
183 const onClear = () => {
184 setPreview(null);
185 setError('');
186 };
187
188 const onClose = () => {
189 abortRef.current?.abort();
190 onAfterSave(false);
191 };
192
193 useCmdEnterSave(
194 onGenerate,
195 !preview?.src && !generating && !!aiImageOptions.prompt && !noCredits,
196 );
197
198 return (
199 <Modal
200 title={__('Generate image with AI', 'extendify-local')}
201 onRequestClose={onClose}
202 isDismissible={false}
203 headerActions={<ModalCloseButton onClick={onClose} />}
204 className="extendify-quick-edit-modal extendify-quick-edit-ai-image"
205 overlayClassName="extendify-quick-edit"
206 bodyOpenClassName={QE_MODAL_BODY_OPEN_CLASS}
207 size="medium"
208 >
209 {error ? (
210 <Notice status="error" isDismissible={false}>
211 {error}
212 </Notice>
213 ) : null}
214 {preview?.src ? (
215 <div className="extendify-quick-edit-ai-preview">
216 <img src={preview.src} alt={aiImageOptions.prompt} />
217 </div>
218 ) : (
219 <form onSubmit={onGenerate} className="extendify-quick-edit-ai-form">
220 <TextareaControl
221 autoFocus
222 label={__('Image prompt', 'extendify-local')}
223 placeholder={__(
224 'Describe the image you want to create',
225 'extendify-local',
226 )}
227 value={aiImageOptions.prompt}
228 onChange={(v) => setAiImageOption('prompt', v)}
229 rows={4}
230 disabled={generating}
231 __nextHasNoMarginBottom
232 />
233 <ToggleGroupControl
234 isBlock
235 label={__('Aspect ratio', 'extendify-local')}
236 value={aiImageOptions.size}
237 onChange={(v) => setAiImageOption('size', v)}
238 __nextHasNoMarginBottom
239 >
240 <ToggleGroupControlOption
241 value="1024x1024"
242 label={
243 // translators: image aspect ratio — a square (1:1) shape.
244 __('Square', 'extendify-local')
245 }
246 />
247 <ToggleGroupControlOption
248 value="1536x1024"
249 label={
250 // translators: image aspect ratio — landscape orientation (wider than tall).
251 __('Landscape', 'extendify-local')
252 }
253 />
254 <ToggleGroupControlOption
255 value="1024x1536"
256 label={
257 // translators: image aspect ratio — portrait orientation (taller than wide).
258 __('Portrait', 'extendify-local')
259 }
260 />
261 </ToggleGroupControl>
262 {generating ? (
263 // biome-ignore lint/a11y/useSemanticElements: deliberate live region; <output> changes display + semantics
264 <div className="extendify-quick-edit-ai-generating" role="status">
265 <Spinner />
266 <span>{__('Generating image…', 'extendify-local')}</span>
267 </div>
268 ) : null}
269 <div className="extendify-quick-edit-ai-credits" aria-live="polite">
270 {sprintf(
271 // translators: %1$d is the number of credits used, %2$d is the total credits available.
272 __('%1$d of %2$d credits used', 'extendify-local'),
273 usedCredits,
274 imageCredits.total,
275 )}
276 </div>
277 </form>
278 )}
279 <div className="extendify-quick-edit-modal-actions">
280 {preview?.src ? (
281 <>
282 <Button variant="tertiary" onClick={onClear} disabled={applying}>
283 {__('Try again', 'extendify-local')}
284 </Button>
285 <Button
286 variant="primary"
287 onClick={onUse}
288 isBusy={applying}
289 disabled={applying}
290 >
291 {__('Use image', 'extendify-local')}
292 </Button>
293 </>
294 ) : (
295 <>
296 <Button variant="tertiary" onClick={onClose}>
297 {__('Cancel', 'extendify-local')}
298 </Button>
299 <Button
300 variant="primary"
301 onClick={onGenerate}
302 isBusy={generating}
303 disabled={generating || !aiImageOptions.prompt || noCredits}
304 >
305 {noCredits
306 ? __('Out of credits', 'extendify-local')
307 : __('Generate', 'extendify-local')}
308 </Button>
309 </>
310 )}
311 </div>
312 </Modal>
313 );
314 };
315