PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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.2.1, at src/QuickEdit/components/modals/AiImagePickerModal.jsx

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