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 / Agent / components / ImagePicker.jsx

ImagePicker.jsx in Extendify 3.2.2, at src/Agent/components/ImagePicker.jsx

504 lines 15.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import {
2 useImageAcquisition,
3 useUnsplashSearch,
4 } from '@agent/hooks/useImageAcquisition';
5 import { preloadImage } from '@agent/lib/replace-image';
6 import { useImageCreditsSync } from '@shared/hooks/useImageCreditsSync';
7 import { useStampedPreview } from '@shared/hooks/useStampedPreview';
8 import {
9 addCustomMediaViewsCss,
10 removeCustomMediaViewsCss,
11 } from '@shared/lib/media-views';
12 import { Spinner } from '@wordpress/components';
13 import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
14 import { __, sprintf } from '@wordpress/i18n';
15 import { close, Icon, pencil } from '@wordpress/icons';
16 import { MediaUpload } from '@wordpress/media-utils';
17 import classNames from 'classnames';
18
19 const TABS = { generate: 'generate', media: 'media', stock: 'unsplash' };
20
21 export const ImagePicker = ({
22 prompt = '',
23 search = '',
24 tab,
25 autoGenerate = false,
26 target,
27 onSelect,
28 onSubmit,
29 onError,
30 footer,
31 }) => {
32 const initialSource = TABS[tab] ?? 'generate';
33 const [source, setSource] = useState(initialSource);
34 const [busy, setBusy] = useState(false);
35 const [error, setError] = useState(null);
36 const [disclose, setDisclose] = useState(false);
37 const [editingPrompt, setEditingPrompt] = useState(false);
38 const [draftPrompt, setDraftPrompt] = useState('');
39 const [editedPrompt, setEditedPrompt] = useState(null);
40 const generateButton = useRef(null);
41 const onSelectRef = useRef(onSelect);
42 onSelectRef.current = onSelect;
43 const previewed = useRef(null);
44
45 const applyPreview = useCallback(async (url) => {
46 previewed.current = url;
47 if (url) await preloadImage(url);
48 await onSelectRef.current?.(url);
49 }, []);
50
51 const {
52 states,
53 imageCredits,
54 generate,
55 attachImage,
56 pickUnsplash,
57 markAwaiting,
58 resolveImage,
59 } = useImageAcquisition({
60 source: 'agent',
61 onUrlReady: (_key, url) => applyPreview(url),
62 });
63
64 useImageCreditsSync();
65
66 const state = states[source];
67 const ready = state?.status === 'ready';
68 const activeUrl = state?.image?.url ?? state?.url ?? null;
69 const previewUrl = useStampedPreview(
70 activeUrl,
71 disclose && source === 'generate',
72 );
73
74 // wp.media rebuilds its frame on open, leaving any still-open modal behind empty.
75 const openedModal = useRef(null);
76
77 const closeMediaModal = useCallback(() => {
78 const remembered = openedModal.current;
79 openedModal.current = null;
80 // A rebuilt frame takes the class with it, so this lookup misses the shell.
81 const mounted = document
82 .querySelector('.image__media-modal')
83 ?.closest('.media-modal');
84 for (const modal of new Set([remembered, mounted].filter(Boolean))) {
85 modal.querySelector('.media-modal-close')?.click();
86 }
87 }, []);
88
89 const openMediaModal = useCallback(
90 (open) => {
91 closeMediaModal();
92 open();
93 openedModal.current =
94 document
95 .querySelector('.image__media-modal')
96 ?.closest('.media-modal') ?? null;
97 },
98 [closeMediaModal],
99 );
100
101 // The picker modal mounts under the Agent without these.
102 useEffect(() => {
103 addCustomMediaViewsCss();
104 const style = document.createElement('style');
105 style.textContent = '.media-modal { z-index: 999999 !important; }';
106 document.head.appendChild(style);
107 return () => {
108 closeMediaModal();
109 removeCustomMediaViewsCss();
110 style.remove();
111 };
112 }, [closeMediaModal]);
113
114 // The first title segment reads as the page name ("Hoodie – Iron Forge Gym").
115 const pageName = document.title.split(/[|–—]/)[0].trim();
116 const stockSeed = search || pageName;
117 const generatePrompt = editedPrompt ?? (prompt || pageName);
118
119 // Re-renders must not re-kick generation and burn extra credits.
120 const started = useRef(false);
121 useEffect(() => {
122 // The image endpoint 400s on an empty prompt; only an asked-for one counts.
123 if (started.current || !autoGenerate || !prompt) return;
124 started.current = true;
125 generate(initialSource, prompt, target?.());
126 }, [autoGenerate, generate, initialSource, prompt, target]);
127
128 useEffect(() => {
129 if (previewed.current === previewUrl) return;
130 applyPreview(previewUrl);
131 }, [previewUrl, applyPreview]);
132
133 const switchSource = (next) => {
134 // Left open, the media modal covers the tab just switched to.
135 closeMediaModal();
136 setSource(next);
137 };
138
139 const submit = async () => {
140 if (busy) return;
141 setBusy(true);
142 setError(null);
143 try {
144 await onSubmit(await resolveImage(source, { disclose }));
145 } catch (failure) {
146 setBusy(false);
147 // Handing it back lets the caller's own flow react — the agent sends
148 // it to the model rather than parking the user on a dead picker.
149 if (onError) return onError(failure);
150 setError(
151 failure?.message || __('Failed to save image', 'extendify-local'),
152 );
153 }
154 };
155
156 return (
157 <div className="mb-4 ms-2 me-2 flex flex-col overflow-hidden rounded-lg border border-gray-300 bg-gray-50">
158 <div
159 className="flex border-0 border-b border-solid border-gray-300 bg-gray-100"
160 role="tablist"
161 >
162 <SourceTab
163 active={source === 'generate'}
164 onClick={() => switchSource('generate')}
165 label={__('Generate', 'extendify-local')}
166 />
167 <SourceTab
168 active={source === 'media'}
169 onClick={() => switchSource('media')}
170 label={__('Media Library', 'extendify-local')}
171 />
172 <SourceTab
173 active={source === 'unsplash'}
174 onClick={() => switchSource('unsplash')}
175 label={__('Stock Photos', 'extendify-local')}
176 />
177 </div>
178 <div className="border-b border-gray-300 bg-white">
179 <div className="flex flex-col gap-2 p-3">
180 {source === 'generate' && (
181 <>
182 {editingPrompt ? (
183 <textarea
184 // biome-ignore lint/a11y/noAutofocus: the pencil opened it to type in
185 autoFocus
186 className="m-0 h-48 w-full resize-none overflow-y-auto rounded-sm border border-gray-300 p-2 text-sm"
187 aria-label={__('Image description', 'extendify-local')}
188 value={draftPrompt}
189 onChange={(event) => setDraftPrompt(event.target.value)}
190 />
191 ) : (
192 <ImageSlot
193 url={ready ? previewUrl : null}
194 generating={state?.status === 'generating'}
195 label={__('Generate an image', 'extendify-local')}
196 whole
197 />
198 )}
199 {state?.status === 'error' && !editingPrompt && (
200 <p className="m-0 p-0 text-sm text-red-700">{state.message}</p>
201 )}
202 {!editingPrompt && (
203 <label className="flex cursor-pointer items-center gap-2 text-sm text-gray-900">
204 <input
205 type="checkbox"
206 className="m-0"
207 checked={disclose}
208 onChange={(event) => setDisclose(event.target.checked)}
209 />
210 <span>
211 {
212 // translators: Checkbox that adds a visible "AI Generated" mark onto the image.
213 __('Label image as AI-generated', 'extendify-local')
214 }
215 </span>
216 </label>
217 )}
218 {editingPrompt ? (
219 <button
220 type="button"
221 className="w-full rounded-sm border border-design-main bg-design-main p-2 text-sm text-white"
222 onClick={() => {
223 setEditedPrompt(draftPrompt.trim() || null);
224 setEditingPrompt(false);
225 // Focus back on Generate: saving must not spend a credit.
226 requestAnimationFrame(() =>
227 generateButton.current?.focus(),
228 );
229 }}
230 >
231 {__('Save', 'extendify-local')}
232 </button>
233 ) : (
234 <div className="flex gap-2">
235 <button
236 type="button"
237 ref={generateButton}
238 className="flex-1 rounded-sm border border-design-main bg-design-main p-2 text-sm text-white disabled:opacity-50"
239 disabled={busy || state?.status === 'generating'}
240 onClick={() =>
241 generate('generate', generatePrompt, target?.())
242 }
243 >
244 {ready || state?.status === 'error'
245 ? __('Try Again', 'extendify-local')
246 : __('Generate Image', 'extendify-local')}
247 </button>
248 <button
249 type="button"
250 aria-label={__('Edit image description', 'extendify-local')}
251 className="flex cursor-pointer items-center justify-center rounded-sm border border-gray-500 bg-white px-2 text-gray-900 disabled:cursor-default disabled:opacity-50"
252 disabled={busy || state?.status === 'generating'}
253 onClick={() => {
254 setDraftPrompt(generatePrompt);
255 setEditingPrompt(true);
256 }}
257 >
258 <Icon fill="currentColor" icon={pencil} size={20} />
259 </button>
260 </div>
261 )}
262 <div className="text-pretty text-center text-xss leading-none text-gray-700">
263 {sprintf(
264 // translators: %1$s is the number of credits remaining, %2$s is the total credits
265 __(
266 'You have %1$s of %2$s daily image credits remaining.',
267 'extendify-local',
268 ),
269 imageCredits.remaining,
270 imageCredits.total,
271 )}
272 </div>
273 </>
274 )}
275 {source === 'media' && (
276 <MediaUpload
277 title={__('Select or Upload Image', 'extendify-local')}
278 onSelect={async (attachment) => {
279 if (!attachment?.url) return;
280 await applyPreview(attachment.url);
281 attachImage('media', {
282 id: attachment.id,
283 url: attachment.url,
284 });
285 }}
286 allowedTypes={['image']}
287 modalClass="image__media-modal"
288 render={({ open }) => {
289 const openLibrary = () => openMediaModal(open);
290 return (
291 <>
292 <ImageSlot
293 url={ready ? activeUrl : null}
294 label={__('Set image', 'extendify-local')}
295 onClick={busy ? undefined : openLibrary}
296 openLabel={
297 ready && activeUrl
298 ? __('Change image', 'extendify-local')
299 : __('Choose an image', 'extendify-local')
300 }
301 />
302 <button
303 type="button"
304 className="w-full rounded-sm border border-design-main bg-design-main p-2 text-sm text-white disabled:opacity-50"
305 disabled={busy}
306 onClick={openLibrary}
307 >
308 {__('Open Media Library', 'extendify-local')}
309 </button>
310 </>
311 );
312 }}
313 />
314 )}
315 {source === 'unsplash' && (
316 <UnsplashTab
317 selectedUrl={ready ? activeUrl : null}
318 disabled={busy}
319 initialSearch={stockSeed}
320 onPick={(photo) => pickUnsplash('unsplash', photo)}
321 onClear={() => markAwaiting('unsplash')}
322 />
323 )}
324 {error && <p className="m-0 p-0 text-sm text-red-700">{error}</p>}
325 </div>
326 </div>
327 {footer({ ready, busy, submit })}
328 </div>
329 );
330 };
331
332 export const PickerActions = ({ children }) => (
333 <div className="flex gap-2 p-3">{children}</div>
334 );
335
336 export const PickerButton = ({ primary, disabled, onClick, children }) => (
337 <button
338 type="button"
339 className={classNames(
340 'flex-1 rounded-sm p-2 text-sm disabled:opacity-50',
341 primary
342 ? 'border border-design-main bg-design-main text-white'
343 : 'border border-gray-500 bg-white text-gray-900',
344 )}
345 disabled={disabled}
346 onClick={onClick}
347 >
348 {children}
349 </button>
350 );
351
352 const SourceTab = ({ active, onClick, label }) => (
353 <button
354 type="button"
355 role="tab"
356 aria-selected={active}
357 className={classNames(
358 // Bordered in both states so switching tabs can't reflow the label.
359 'flex-1 cursor-pointer border-x border-solid border-transparent px-1.5 py-2 text-xs text-gray-900 transition-colors',
360 active
361 ? '-mb-px border-gray-300 bg-white font-medium first:border-s-transparent last:border-e-transparent'
362 : 'bg-transparent',
363 )}
364 onClick={onClick}
365 >
366 {label}
367 </button>
368 );
369
370 const ImageSlot = ({ url, generating, label, onClick, openLabel, whole }) => {
371 if (onClick) {
372 return (
373 <button
374 type="button"
375 aria-label={openLabel}
376 className="block w-full cursor-pointer border-0 bg-transparent p-0"
377 onClick={onClick}
378 >
379 <ImageSlot url={url} generating={generating} label={label} />
380 </button>
381 );
382 }
383 if (generating) {
384 return (
385 <div className="relative h-48 w-full">
386 <output
387 aria-label={__('Generating image...', 'extendify-local')}
388 className="absolute inset-0 block animate-pulse rounded-sm bg-gradient-to-br from-gray-100 to-gray-300"
389 />
390 <Spinner className="absolute inset-0 m-auto h-6 w-6" />
391 </div>
392 );
393 }
394 if (url) {
395 return (
396 <img
397 className={classNames(
398 'm-0 h-48 w-full rounded-sm border border-gray-300',
399 // Cover crops the corner the disclosure pill sits in.
400 whole ? 'bg-gray-100 object-contain' : 'object-cover',
401 )}
402 src={url}
403 alt={__('Selected image preview', 'extendify-local')}
404 />
405 );
406 }
407 return (
408 <div
409 data-testid="image-placeholder"
410 className="flex h-48 w-full items-center justify-center rounded-sm bg-gradient-to-br from-gray-100 to-gray-300"
411 >
412 {label && <p className="m-0 p-0 text-sm text-gray-700">{label}</p>}
413 </div>
414 );
415 };
416
417 const UnsplashTab = ({
418 selectedUrl,
419 disabled,
420 initialSearch,
421 onPick,
422 onClear,
423 }) => {
424 const [search, setSearch] = useState(initialSearch);
425 const [searchDebounced, setSearchDebounced] = useState(initialSearch);
426 const { data: images, loading } = useUnsplashSearch(searchDebounced, 'user');
427
428 useEffect(() => {
429 if (!search) return setSearchDebounced('');
430 const id = setTimeout(() => setSearchDebounced(search), 750);
431 return () => clearTimeout(id);
432 }, [search]);
433
434 return (
435 <>
436 {selectedUrl ? (
437 <div className="relative h-48 w-full">
438 <img
439 className="m-0 h-full w-full rounded-sm border border-gray-300 object-cover"
440 src={selectedUrl}
441 alt={__('Selected image preview', 'extendify-local')}
442 />
443 <button
444 type="button"
445 aria-label={__('Clear selected image', 'extendify-local')}
446 className="absolute end-1 top-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-sm border-0 bg-white/90 p-0 text-gray-900 hover:bg-white"
447 disabled={disabled}
448 onClick={onClear}
449 >
450 <Icon fill="currentColor" icon={close} size={18} />
451 </button>
452 </div>
453 ) : loading ? (
454 <output
455 aria-label={__('Loading photos...', 'extendify-local')}
456 className="grid h-48 w-full animate-pulse grid-cols-3 gap-1"
457 >
458 {Array.from({ length: 6 }, (_, index) => (
459 <div key={index} className="rounded-sm bg-gray-200" />
460 ))}
461 </output>
462 ) : images?.length ? (
463 <div className="grid h-48 auto-rows-min content-start grid-cols-3 gap-1 overflow-y-auto">
464 {images.map((photo) => (
465 <button
466 key={photo.id}
467 type="button"
468 className="m-0 border-0 bg-transparent p-0"
469 disabled={disabled}
470 aria-label={
471 photo.alt_description || __('Stock photo', 'extendify-local')
472 }
473 onClick={() => onPick(photo)}
474 >
475 <img
476 className="m-0 aspect-square w-full rounded-sm object-cover"
477 src={photo.urls?.small}
478 alt={photo.alt_description ?? ''}
479 />
480 </button>
481 ))}
482 </div>
483 ) : (
484 <div className="flex h-48 w-full items-center justify-center rounded-sm bg-gradient-to-br from-gray-100 to-gray-300 px-4">
485 <p className="m-0 p-0 text-center text-sm text-gray-700">
486 {__('No images found.', 'extendify-local')}
487 </p>
488 </div>
489 )}
490 <input
491 type="search"
492 className="w-full rounded-sm border border-gray-300 p-2 text-sm"
493 placeholder={__('Search photos...', 'extendify-local')}
494 aria-label={__('Search photos', 'extendify-local')}
495 value={search}
496 onChange={(event) => {
497 setSearch(event.target.value);
498 if (selectedUrl) onClear();
499 }}
500 />
501 </>
502 );
503 };
504