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 / InlineEditor.jsx

InlineEditor.jsx in Extendify 3.1.6, at src/QuickEdit/components/InlineEditor.jsx

542 lines 17.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import {
2 addCustomMediaViewsCss,
3 removeCustomMediaViewsCss,
4 } from '@shared/lib/media-views';
5 import { track } from '@shared/lib/track';
6 import { useEffect, useRef, useState } from '@wordpress/element';
7 import { __ } from '@wordpress/i18n';
8 import { loadProduct, save, saveProduct } from '../lib/api';
9 import { invalidateBlockSource } from '../lib/block-source-cache';
10 import { splice } from '../lib/dom';
11 import { friendlyMessage } from '../lib/errors';
12 import { closeModal, mountModal } from '../lib/modal-root';
13 import { useQuickEditStore } from '../state/store';
14 import { pushUndo } from '../state/undo';
15 import { BlockTextEditor } from './BlockTextEditor';
16 import { ErrorPill } from './ErrorPill';
17 import { AiImagePickerModal } from './modals/AiImagePickerModal';
18 import { NavItemModal } from './modals/NavItemModal';
19 import { ProductPriceModal } from './modals/ProductPriceModal';
20 import { ProductTextModal } from './modals/ProductTextModal';
21 import { SiteIdentityModal } from './modals/SiteIdentityModal';
22 import { SocialLinkModal } from './modals/SocialLinkModal';
23 import { UnsplashImagePickerModal } from './modals/UnsplashImagePickerModal';
24 import { WPFormsFieldModal } from './modals/WPFormsFieldModal';
25
26 // id parsed from the wp-image-N class; null when the image is a raw URL.
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 const TEXT_STRATEGIES = {
46 'core/paragraph': {
47 textTarget: (el) => el,
48 textField: 'content',
49 extras: ['align'],
50 },
51 'core/heading': {
52 textTarget: (el) => el,
53 textField: 'content',
54 extras: ['align'],
55 },
56 'core/button': {
57 textTarget: (el) => el.querySelector('a'),
58 textField: 'text',
59 extras: ['url'],
60 },
61 };
62
63 const PICKER_STRATEGIES = {
64 'core/image': { field: 'image' },
65 'core/cover': { field: 'background' },
66 'core/media-text:image': { field: 'media' },
67 'product:image': { field: 'image' },
68 };
69
70 // Mounted via lib/modal-root so wp-components' Modal portal stays outside
71 // our prefix scope. Anything single-field or out-of-band (wp_options) goes here.
72 const MODAL_BLOCK_TYPES = new Set([
73 'core/site-title',
74 'core/site-tagline',
75 'core/site-logo',
76 'core/social-link',
77 'core/navigation-link',
78 'core/navigation-submenu',
79 // product:image is omitted — it routes through ImagePicker so it
80 // shares the Library/Upload/AI/Unsplash UX with core/image.
81 'product:name',
82 'product:short_description',
83 'product:description',
84 'product:price',
85 'wpforms:field',
86 ]);
87
88 const SITE_IDENTITY_KIND_BY_BLOCK_TYPE = {
89 'core/site-title': 'title',
90 'core/site-tagline': 'tagline',
91 'core/site-logo': 'logo',
92 };
93
94 export const InlineEditor = () => {
95 const selected = useQuickEditStore((s) => s.selected);
96 const clearSelected = useQuickEditStore((s) => s.clearSelected);
97
98 useEffect(() => {
99 if (!selected) return undefined;
100 const blockType = selected.blockType;
101 if (!MODAL_BLOCK_TYPES.has(blockType)) return undefined;
102
103 // Reload on save so site-identity / nav-label changes propagate
104 // to every render of those blocks on the page.
105 const onAfterSave = (didSave) => {
106 closeModal(false);
107 clearSelected();
108 if (didSave) window.location.reload();
109 };
110
111 let element = null;
112 if (SITE_IDENTITY_KIND_BY_BLOCK_TYPE[blockType]) {
113 element = (
114 <SiteIdentityModal
115 kind={SITE_IDENTITY_KIND_BY_BLOCK_TYPE[blockType]}
116 onAfterSave={onAfterSave}
117 />
118 );
119 } else if (blockType === 'core/social-link') {
120 element = (
121 <SocialLinkModal selected={selected} onAfterSave={onAfterSave} />
122 );
123 } else if (
124 blockType === 'core/navigation-link' ||
125 blockType === 'core/navigation-submenu'
126 ) {
127 element = <NavItemModal selected={selected} onAfterSave={onAfterSave} />;
128 } else if (
129 blockType === 'product:name' ||
130 blockType === 'product:short_description' ||
131 blockType === 'product:description'
132 ) {
133 element = (
134 <ProductTextModal
135 productId={selected.productId}
136 field={selected.productField}
137 onAfterSave={onAfterSave}
138 />
139 );
140 } else if (blockType === 'product:price') {
141 element = (
142 <ProductPriceModal
143 productId={selected.productId}
144 onAfterSave={onAfterSave}
145 />
146 );
147 } else if (blockType === 'wpforms:field') {
148 element = (
149 <WPFormsFieldModal
150 formId={selected.formId}
151 fieldId={selected.fieldId}
152 onAfterSave={onAfterSave}
153 />
154 );
155 }
156 if (element) mountModal(element);
157
158 return () => {
159 closeModal(false);
160 };
161 }, [selected, clearSelected]);
162
163 if (!selected) return null;
164 if (MODAL_BLOCK_TYPES.has(selected.blockType)) return null;
165
166 if (TEXT_STRATEGIES[selected.blockType]) {
167 return <BlockTextEditor selected={selected} />;
168 }
169 if (PICKER_STRATEGIES[selected.blockType]) {
170 // Key on blockId so React remounts the picker — and its
171 // `ImagePickerMenu`, which positions itself in `useState(compute)`
172 // once at mount — when the user clicks a different image while
173 // the menu is open. Without the key the same instance re-renders
174 // with the new `selected` prop but keeps its stale `pos` state,
175 // leaving the menu visually pinned to the prior image.
176 return (
177 <ImagePicker
178 key={selected.blockId ?? selected.el}
179 selected={selected}
180 field={PICKER_STRATEGIES[selected.blockType].field}
181 />
182 );
183 }
184 return <UnsupportedNotice blockType={selected.blockType} />;
185 };
186
187 const ImagePicker = ({ selected, field }) => {
188 const [error, setError] = useState(null);
189 const clearSelected = useQuickEditStore((s) => s.clearSelected);
190
191 useEffect(() => {
192 const onDoc = (e) => {
193 const menu = document.getElementById('extendify-quick-edit-image-menu');
194 if (menu?.contains(e.target)) return;
195 // Hover-bar clicks are routed by hover-bar.js' toggle.
196 const bar = document.querySelector('.extendify-quick-edit-bar');
197 if (bar?.contains(e.target)) return;
198 clearSelected();
199 };
200 const onKey = (e) => {
201 if (e.key === 'Escape') {
202 e.preventDefault();
203 clearSelected();
204 }
205 };
206 // Defer click binding so the click that opened the menu
207 // doesn't immediately close it.
208 const t = window.setTimeout(() => {
209 document.addEventListener('click', onDoc);
210 }, 0);
211 document.addEventListener('keydown', onKey);
212 return () => {
213 window.clearTimeout(t);
214 document.removeEventListener('click', onDoc);
215 document.removeEventListener('keydown', onKey);
216 };
217 }, [clearSelected]);
218
219 const openFrame = (initialTab) => {
220 if (!window.wp?.media) {
221 setError(__('Media library is not loaded.', 'extendify-local'));
222 return;
223 }
224 const mode = initialTab === 'upload' ? 'upload' : 'browse';
225 track('quick_edit_action', {
226 element: selected.blockType,
227 type: `image_${mode}`,
228 });
229 const frame = window.wp.media({
230 title:
231 mode === 'upload'
232 ? __('Upload image', 'extendify-local')
233 : __('Pick from media library', 'extendify-local'),
234 button: { text: __('Use image', 'extendify-local') },
235 library: { type: 'image' },
236 multiple: false,
237 });
238 // Tag QE's modal element with mode + uploading classes so our CSS
239 // targets ONLY this frame's chrome. Targeting body globally would
240 // leak into any other wp.media frame open at the same time — the
241 // AI Agent's "Change image" flow uses its own MediaUpload frame,
242 // and the agent's media library went blank because the upload-
243 // overlay CSS painted a white sheet over every `.media-frame-
244 // content`. Modal-scoped classes prevent that.
245 frame.on('open', () => {
246 const $modal = frame.modal?.$el;
247 $modal?.addClass(`extendify-quick-edit-mode-${mode}`);
248 if (frame.content?.mode) frame.content.mode(mode);
249
250 // Single-click auto-confirms; wp.media's "Use image" toolbar button
251 // is otherwise required. trigger('select') alone leaves the modal
252 // open, so close() too. For uploads, wait for the attachment's
253 // `uploading` flag to flip and overlay our own spinner so wp.media
254 // doesn't flash to the library view mid-upload.
255 const selection = frame.state()?.get?.('selection');
256 if (selection) {
257 selection.on('add', (att) => {
258 const commit = () => {
259 $modal?.removeClass('extendify-quick-edit-media-uploading');
260 frame.state().trigger('select');
261 frame.close();
262 };
263 if (att?.get?.('uploading')) {
264 $modal?.addClass('extendify-quick-edit-media-uploading');
265 const onChange = () => {
266 if (!att.get('uploading')) {
267 att.off('change:uploading', onChange);
268 commit();
269 }
270 };
271 att.on('change:uploading', onChange);
272 } else {
273 commit();
274 }
275 });
276 }
277 });
278 const cleanupModeClass = () => {
279 const $modal = frame.modal?.$el;
280 $modal?.removeClass(`extendify-quick-edit-mode-${mode}`);
281 $modal?.removeClass('extendify-quick-edit-media-uploading');
282 removeCustomMediaViewsCss();
283 };
284 frame.on('close', cleanupModeClass);
285
286 let pickedAndSaving = false;
287 frame.on('select', async () => {
288 pickedAndSaving = true;
289 const att = frame.state().get('selection').first()?.toJSON();
290 if (!att) {
291 clearSelected();
292 return;
293 }
294 // Product images cascade to many surfaces; reload after save instead of splicing.
295 if (selected.source?.kind === 'product') {
296 let beforeImageId = 0;
297 try {
298 const cur = await loadProduct(selected.source.id);
299 beforeImageId = Number(cur?.image_id) || 0;
300 } catch (_) {
301 // non-fatal — undo entry just won't have a before-state.
302 }
303 try {
304 await saveProduct({
305 productId: selected.source.id,
306 field: 'image',
307 value: att.id,
308 });
309 if (beforeImageId && beforeImageId !== att.id) {
310 pushUndo({
311 kind: 'product-image',
312 productReplay: true,
313 productId: selected.source.id,
314 field: 'image',
315 beforeValue: beforeImageId,
316 });
317 }
318 track('save', { kind: 'product', field: 'image' });
319 window.location.reload();
320 } catch (err) {
321 track('save_failed', { kind: 'product', field: 'image' });
322 setError(friendlyMessage(err));
323 }
324 return;
325 }
326
327 const before = readImageAttrs(selected.mediaEl ?? selected.el);
328 try {
329 const res = await save({
330 source: selected.source,
331 blockId: selected.blockId,
332 blockType: selected.blockName ?? selected.blockType,
333 patches: [
334 {
335 fieldKey: field,
336 value: {
337 url: att.url,
338 id: att.id,
339 alt: att.alt || '',
340 },
341 },
342 ],
343 });
344 if (!res.rendered) throw new Error('No rendered HTML');
345 const newEl = splice(selected.el, res.rendered);
346 if (!newEl) throw new Error('Splice failed');
347 invalidateBlockSource(selected.source, selected.blockId);
348 if (before) {
349 pushUndo({
350 kind: 'image',
351 source: selected.source,
352 blockId: selected.blockId,
353 blockType: selected.blockName ?? selected.blockType,
354 patches: [{ fieldKey: field, value: before }],
355 });
356 }
357 track('image_replaced', { source: 'wp_media' });
358 clearSelected();
359 } catch (err) {
360 track('save_failed', { kind: 'image', source: 'wp_media' });
361 setError(friendlyMessage(err));
362 }
363 });
364 frame.on('close', () => {
365 if (!pickedAndSaving) clearSelected();
366 });
367 // Armor wp.media's chrome before it paints. On the live frontend the
368 // site theme's text/heading colors otherwise bleed into the modal —
369 // "Upload image", "Drop files to upload", etc. render in the theme's
370 // font and color. Mirrors the AI Agent's media flows; the shared
371 // helper re-emits wp.media's own CSS with !important. See
372 // @shared/lib/media-views.
373 addCustomMediaViewsCss();
374 frame.open();
375 };
376
377 const openImageModal = (Component) => {
378 const type =
379 Component === AiImagePickerModal ? 'image_ai' : 'image_unsplash';
380 track('quick_edit_action', { element: selected.blockType, type });
381 const isProduct = selected.source?.kind === 'product';
382 const onAfterSave = (didSave) => {
383 closeModal(false);
384 if (didSave) {
385 if (isProduct) {
386 window.location.reload();
387 } else {
388 clearSelected();
389 }
390 }
391 };
392 mountModal(
393 <Component selected={selected} field={field} onAfterSave={onAfterSave} />,
394 );
395 };
396
397 if (error) {
398 return <ErrorPill message={error} onDismiss={clearSelected} />;
399 }
400
401 return (
402 <ImagePickerMenu selected={selected}>
403 <MenuItem onClick={() => openFrame('browse')}>
404 {__('Pick from media library', 'extendify-local')}
405 </MenuItem>
406 <MenuItem onClick={() => openFrame('upload')}>
407 {__('Upload', 'extendify-local')}
408 </MenuItem>
409 <MenuItem onClick={() => openImageModal(AiImagePickerModal)}>
410 {__('Generate with AI', 'extendify-local')}
411 </MenuItem>
412 <MenuItem onClick={() => openImageModal(UnsplashImagePickerModal)}>
413 {__('Search for new image', 'extendify-local')}
414 </MenuItem>
415 </ImagePickerMenu>
416 );
417 };
418
419 // Re-anchor on scroll/resize. The menu always drops from the hover bar — the
420 // pill the user clicked — which `positionBar` (lib/hover-bar.js) places above
421 // OR below the image depending on viewport room, and which stays mounted for
422 // picker blocks. Reading the live bar keeps the menu pinned under the pill
423 // during scroll: hover-bar.js' own scroll listener is registered first, so it
424 // repositions the bar before this one reads it. Falls back to the image's top
425 // edge (where the bar would have sat) if the bar is somehow gone.
426 const ImagePickerMenu = ({ selected, children }) => {
427 const menuRef = useRef(null);
428 const compute = () => {
429 const bar = document
430 .querySelector('.extendify-quick-edit-bar')
431 ?.getBoundingClientRect();
432 // Anchor to the media figure for media-text so the menu lands over the
433 // image, not the whole block (which spans the text side too).
434 const image =
435 (selected.mediaEl ?? selected.el)?.getBoundingClientRect?.() ??
436 selected.anchorRect ??
437 null;
438 const anchor = bar ?? image;
439 if (!anchor) return { top: 0, left: 0 };
440 const MENU_W = 220;
441 const MENU_H = 180;
442 const GAP = 6;
443 // Center on the pill (itself centered on the picked element). Left-
444 // aligning to the element's left edge dropped the menu into dead space
445 // when the picked element was a viewport-wide cover (`anchor.left ≈ 0`).
446 let left = anchor.left + (anchor.width - MENU_W) / 2;
447 if (left + MENU_W > window.innerWidth - 4) {
448 left = window.innerWidth - MENU_W - 4;
449 }
450 if (left < 4) left = 4;
451 // Drop below the pill; flip above it when there isn't room below.
452 let top = bar ? bar.bottom + GAP : image.top;
453 if (top + MENU_H > window.innerHeight - 4) {
454 top = Math.max(4, (bar ? bar.top : image.bottom) - MENU_H - GAP);
455 }
456 if (top < 4) top = 4;
457 return { top, left };
458 };
459 const [pos, setPos] = useState(compute);
460 useEffect(() => {
461 const handler = () => setPos(compute());
462 window.addEventListener('scroll', handler, {
463 capture: true,
464 passive: true,
465 });
466 window.addEventListener('resize', handler);
467 return () => {
468 window.removeEventListener('scroll', handler, { capture: true });
469 window.removeEventListener('resize', handler);
470 };
471 }, [selected.el]);
472
473 // Standard menu pattern: focus the first item on open. The menu renders
474 // at the end of <body>, so Tab alone never reaches it — without this,
475 // keyboard users can't operate the picker at all.
476 useEffect(() => {
477 menuRef.current
478 ?.querySelector('[role="menuitem"]')
479 ?.focus({ preventScroll: true });
480 }, []);
481
482 const onKeyDown = (e) => {
483 if (e.key === 'Escape') {
484 // Restore focus to the picked block before the document-level
485 // escape handler (global-escape.js) clears the selection and
486 // unmounts this menu. Running here — a React handler on the
487 // quick-edit root — beats those document listeners to it.
488 selected.el?.focus?.({ preventScroll: true });
489 return;
490 }
491 if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
492 const items = [
493 ...(menuRef.current?.querySelectorAll('[role="menuitem"]') ?? []),
494 ];
495 if (!items.length) return;
496 e.preventDefault();
497 const cur = items.indexOf(document.activeElement);
498 const last = items.length - 1;
499 let next;
500 if (e.key === 'Home') next = 0;
501 else if (e.key === 'End') next = last;
502 else if (e.key === 'ArrowDown') next = cur < last ? cur + 1 : 0;
503 else next = cur > 0 ? cur - 1 : last;
504 items[next]?.focus({ preventScroll: true });
505 };
506
507 return (
508 <div
509 id="extendify-quick-edit-image-menu"
510 role="menu"
511 ref={menuRef}
512 className="extendify-quick-edit-image-menu fixed z-high flex min-w-[220px] flex-col gap-[2px] rounded-[12px] bg-white p-[6px] font-qe shadow-[0_12px_28px_-8px_rgba(15,23,42,0.25),0_0_0_1px_rgba(15,23,42,0.05)]"
513 style={pos}
514 onKeyDown={onKeyDown}
515 >
516 {children}
517 </div>
518 );
519 };
520
521 const MenuItem = ({ onClick, children }) => (
522 <button
523 type="button"
524 role="menuitem"
525 className="flex w-full cursor-pointer items-center justify-start rounded-[8px] border-0 bg-transparent px-[12px] py-[10px] text-left text-[13px] font-medium leading-[1.4] text-gray-900 transition-[background] duration-[120ms] hover:bg-gray-100 focus-visible:outline-offset-[-2px] focus-visible:[outline:2px_solid_var(--color-design-main)]"
526 onMouseDown={(e) => e.preventDefault()}
527 onClick={onClick}
528 >
529 {children}
530 </button>
531 );
532
533 const UnsupportedNotice = ({ blockType }) => {
534 const clearSelected = useQuickEditStore((s) => s.clearSelected);
535 return (
536 <ErrorPill
537 message={`${__('No editor for this block type yet.', 'extendify-local')} (${blockType})`}
538 onDismiss={clearSelected}
539 />
540 );
541 };
542