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

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

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