PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 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 All 128 releases
extendify / src / QuickEdit / components / modals / AiImagePickerModal.jsx

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

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