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

335 lines 9.6 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 { 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 let attachment;
108 try {
109 attachment = await importImage(preview.src, {
110 alt: preview.alt,
111 filename: 'ai-image.jpg',
112 caption: '',
113 aiGenerated: true,
114 disclose,
115 });
116 } catch (_e) {
117 attachment = await importImageServer(preview.src, {
118 alt: preview.alt,
119 caption: '',
120 aiGenerated: true,
121 disclose,
122 });
123 }
124 const mediaId = attachment?.id;
125 if (!mediaId) throw new Error('No media id returned');
126
127 // Product image: write the featured image via WC's save
128 // endpoint and reload (the same image cascades to many
129 // surfaces — splice can't reach all of them).
130 if (selected.source?.kind === 'product') {
131 let beforeImageId = 0;
132 try {
133 const cur = await loadProduct(selected.source.id);
134 beforeImageId = Number(cur?.image_id) || 0;
135 } catch (_) {
136 // non-fatal — undo entry just won't have a before-state
137 }
138 await saveProduct({
139 productId: selected.source.id,
140 field: 'image',
141 value: mediaId,
142 });
143 if (beforeImageId && beforeImageId !== mediaId) {
144 pushUndo({
145 kind: 'product-image',
146 productReplay: true,
147 productId: selected.source.id,
148 field: 'image',
149 beforeValue: beforeImageId,
150 });
151 }
152 track('image_replaced', { source: 'ai', kind: 'product' });
153 onAfterSave(true);
154 return;
155 }
156
157 const before = readImageAttrs(selected.mediaEl ?? selected.el);
158 const res = await save({
159 source: selected.source,
160 blockId: selected.blockId,
161 blockType: selected.blockName ?? selected.blockType,
162 patches: [
163 {
164 fieldKey: field,
165 value: {
166 url: attachment.url || attachment.source_url,
167 id: mediaId,
168 alt: attachment.alt_text ?? '',
169 },
170 },
171 ],
172 });
173 if (!res.rendered) throw new Error('No rendered HTML');
174 const newEl = splice(selected.el, res.rendered);
175 if (!newEl) throw new Error('Splice failed');
176 invalidateBlockSource(selected.source, selected.blockId);
177 if (before) {
178 pushUndo({
179 kind: 'image',
180 source: selected.source,
181 blockId: selected.blockId,
182 blockType: selected.blockName ?? selected.blockType,
183 patches: [{ fieldKey: field, value: before }],
184 });
185 }
186 track('image_replaced', { source: 'ai' });
187 onAfterSave(true);
188 } catch (err) {
189 track('save_failed', { kind: 'image', source: 'ai' });
190 setError(friendlyMessage(err));
191 setApplying(false);
192 }
193 };
194
195 const onClear = () => {
196 setPreview(null);
197 setError('');
198 };
199
200 const onClose = () => {
201 abortRef.current?.abort();
202 onAfterSave(false);
203 };
204
205 useCmdEnterSave(
206 onGenerate,
207 !preview?.src && !generating && !!aiImageOptions.prompt && !noCredits,
208 );
209
210 return (
211 <Modal
212 title={__('Generate image with AI', 'extendify-local')}
213 onRequestClose={onClose}
214 isDismissible={false}
215 headerActions={<ModalCloseButton onClick={onClose} />}
216 className="extendify-quick-edit-modal extendify-quick-edit-ai-image"
217 overlayClassName="extendify-quick-edit"
218 bodyOpenClassName={QE_MODAL_BODY_OPEN_CLASS}
219 size="medium"
220 >
221 {error ? (
222 <Notice status="error" isDismissible={false}>
223 {error}
224 </Notice>
225 ) : null}
226 {preview?.src ? (
227 <div className="extendify-quick-edit-ai-preview">
228 <img src={previewSrc} alt={preview.alt} />
229 </div>
230 ) : (
231 <form onSubmit={onGenerate} className="extendify-quick-edit-ai-form">
232 <TextareaControl
233 autoFocus
234 label={__('Image description', 'extendify-local')}
235 placeholder={__(
236 'Describe the image you want to create',
237 'extendify-local',
238 )}
239 value={aiImageOptions.prompt}
240 onChange={(v) => setAiImageOption('prompt', v)}
241 rows={4}
242 disabled={generating}
243 __nextHasNoMarginBottom
244 />
245 <ToggleGroupControl
246 isBlock
247 label={__('Aspect ratio', 'extendify-local')}
248 value={aiImageOptions.size}
249 onChange={(v) => setAiImageOption('size', v)}
250 __nextHasNoMarginBottom
251 >
252 <ToggleGroupControlOption
253 value="1024x1024"
254 label={
255 // translators: image aspect ratio — a square (1:1) shape.
256 __('Square', 'extendify-local')
257 }
258 />
259 <ToggleGroupControlOption
260 value="1536x1024"
261 label={
262 // translators: image aspect ratio — landscape orientation (wider than tall).
263 __('Landscape', 'extendify-local')
264 }
265 />
266 <ToggleGroupControlOption
267 value="1024x1536"
268 label={
269 // translators: image aspect ratio — portrait orientation (taller than wide).
270 __('Portrait', 'extendify-local')
271 }
272 />
273 </ToggleGroupControl>
274 <CheckboxControl
275 __nextHasNoMarginBottom
276 // translators: Checkbox that adds a visible "AI Generated" mark onto the image.
277 label={__('Label image as AI-generated', 'extendify-local')}
278 checked={disclose}
279 onChange={setDisclose}
280 disabled={generating}
281 />
282 {generating ? (
283 // biome-ignore lint/a11y/useSemanticElements: deliberate live region; <output> changes display + semantics
284 <div className="extendify-quick-edit-ai-generating" role="status">
285 <Spinner />
286 <span>{__('Generating image…', 'extendify-local')}</span>
287 </div>
288 ) : null}
289 <div className="extendify-quick-edit-ai-credits" aria-live="polite">
290 {sprintf(
291 // translators: %1$d is the number of credits used, %2$d is the total credits available.
292 __('%1$d of %2$d credits used', 'extendify-local'),
293 usedCredits,
294 imageCredits.total,
295 )}
296 </div>
297 </form>
298 )}
299 <div className="extendify-quick-edit-modal-actions">
300 {preview?.src ? (
301 <>
302 <Button variant="tertiary" onClick={onClear} disabled={applying}>
303 {__('Try again', 'extendify-local')}
304 </Button>
305 <Button
306 variant="primary"
307 onClick={onUse}
308 isBusy={applying}
309 disabled={applying}
310 >
311 {__('Use image', 'extendify-local')}
312 </Button>
313 </>
314 ) : (
315 <>
316 <Button variant="tertiary" onClick={onClose}>
317 {__('Cancel', 'extendify-local')}
318 </Button>
319 <Button
320 variant="primary"
321 onClick={onGenerate}
322 isBusy={generating}
323 disabled={generating || !aiImageOptions.prompt || noCredits}
324 >
325 {noCredits
326 ? __('Out of credits', 'extendify-local')
327 : __('Generate', 'extendify-local')}
328 </Button>
329 </>
330 )}
331 </div>
332 </Modal>
333 );
334 };
335