PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
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.0, at src/QuickEdit/components/InlineEditor.jsx

538 lines 17.2 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 { useEffect, useRef, useState } from '@wordpress/element';
6 import { __ } from '@wordpress/i18n';
7 import { loadProduct, save, saveProduct } from '../lib/api';
8 import { invalidateBlockSource } from '../lib/block-source-cache';
9 import { splice } from '../lib/dom';
10 import { friendlyMessage } from '../lib/errors';
11 import { track } from '../lib/insights';
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('image_source_chosen', { source: mode });
226 const frame = window.wp.media({
227 title:
228 mode === 'upload'
229 ? __('Upload image', 'extendify-local')
230 : __('Pick from media library', 'extendify-local'),
231 button: { text: __('Use image', 'extendify-local') },
232 library: { type: 'image' },
233 multiple: false,
234 });
235 // Tag QE's modal element with mode + uploading classes so our CSS
236 // targets ONLY this frame's chrome. Targeting body globally would
237 // leak into any other wp.media frame open at the same time — the
238 // AI Agent's "Change image" flow uses its own MediaUpload frame,
239 // and the agent's media library went blank because the upload-
240 // overlay CSS painted a white sheet over every `.media-frame-
241 // content`. Modal-scoped classes prevent that.
242 frame.on('open', () => {
243 const $modal = frame.modal?.$el;
244 $modal?.addClass(`extendify-quick-edit-mode-${mode}`);
245 if (frame.content?.mode) frame.content.mode(mode);
246
247 // Single-click auto-confirms; wp.media's "Use image" toolbar button
248 // is otherwise required. trigger('select') alone leaves the modal
249 // open, so close() too. For uploads, wait for the attachment's
250 // `uploading` flag to flip and overlay our own spinner so wp.media
251 // doesn't flash to the library view mid-upload.
252 const selection = frame.state()?.get?.('selection');
253 if (selection) {
254 selection.on('add', (att) => {
255 const commit = () => {
256 $modal?.removeClass('extendify-quick-edit-media-uploading');
257 frame.state().trigger('select');
258 frame.close();
259 };
260 if (att?.get?.('uploading')) {
261 $modal?.addClass('extendify-quick-edit-media-uploading');
262 const onChange = () => {
263 if (!att.get('uploading')) {
264 att.off('change:uploading', onChange);
265 commit();
266 }
267 };
268 att.on('change:uploading', onChange);
269 } else {
270 commit();
271 }
272 });
273 }
274 });
275 const cleanupModeClass = () => {
276 const $modal = frame.modal?.$el;
277 $modal?.removeClass(`extendify-quick-edit-mode-${mode}`);
278 $modal?.removeClass('extendify-quick-edit-media-uploading');
279 removeCustomMediaViewsCss();
280 };
281 frame.on('close', cleanupModeClass);
282
283 let pickedAndSaving = false;
284 frame.on('select', async () => {
285 pickedAndSaving = true;
286 const att = frame.state().get('selection').first()?.toJSON();
287 if (!att) {
288 clearSelected();
289 return;
290 }
291 // Product images cascade to many surfaces; reload after save instead of splicing.
292 if (selected.source?.kind === 'product') {
293 let beforeImageId = 0;
294 try {
295 const cur = await loadProduct(selected.source.id);
296 beforeImageId = Number(cur?.image_id) || 0;
297 } catch (_) {
298 // non-fatal — undo entry just won't have a before-state.
299 }
300 try {
301 await saveProduct({
302 productId: selected.source.id,
303 field: 'image',
304 value: att.id,
305 });
306 if (beforeImageId && beforeImageId !== att.id) {
307 pushUndo({
308 kind: 'product-image',
309 productReplay: true,
310 productId: selected.source.id,
311 field: 'image',
312 beforeValue: beforeImageId,
313 });
314 }
315 track('save', { kind: 'product', field: 'image' });
316 window.location.reload();
317 } catch (err) {
318 track('save_failed', { kind: 'product', field: 'image' });
319 setError(friendlyMessage(err));
320 }
321 return;
322 }
323
324 const before = readImageAttrs(selected.mediaEl ?? selected.el);
325 try {
326 const res = await save({
327 source: selected.source,
328 blockId: selected.blockId,
329 blockType: selected.blockName ?? selected.blockType,
330 patches: [
331 {
332 fieldKey: field,
333 value: {
334 url: att.url,
335 id: att.id,
336 alt: att.alt || '',
337 },
338 },
339 ],
340 });
341 if (!res.rendered) throw new Error('No rendered HTML');
342 const newEl = splice(selected.el, res.rendered);
343 if (!newEl) throw new Error('Splice failed');
344 invalidateBlockSource(selected.source, selected.blockId);
345 if (before) {
346 pushUndo({
347 kind: 'image',
348 source: selected.source,
349 blockId: selected.blockId,
350 blockType: selected.blockName ?? selected.blockType,
351 patches: [{ fieldKey: field, value: before }],
352 });
353 }
354 track('image_replaced', { source: 'wp_media' });
355 clearSelected();
356 } catch (err) {
357 track('save_failed', { kind: 'image', source: 'wp_media' });
358 setError(friendlyMessage(err));
359 }
360 });
361 frame.on('close', () => {
362 if (!pickedAndSaving) clearSelected();
363 });
364 // Armor wp.media's chrome before it paints. On the live frontend the
365 // site theme's text/heading colors otherwise bleed into the modal —
366 // "Upload image", "Drop files to upload", etc. render in the theme's
367 // font and color. Mirrors the AI Agent's media flows; the shared
368 // helper re-emits wp.media's own CSS with !important. See
369 // @shared/lib/media-views.
370 addCustomMediaViewsCss();
371 frame.open();
372 };
373
374 const openImageModal = (Component) => {
375 const source = Component === AiImagePickerModal ? 'ai' : 'unsplash';
376 track('image_source_chosen', { source });
377 const isProduct = selected.source?.kind === 'product';
378 const onAfterSave = (didSave) => {
379 closeModal(false);
380 if (didSave) {
381 if (isProduct) {
382 window.location.reload();
383 } else {
384 clearSelected();
385 }
386 }
387 };
388 mountModal(
389 <Component selected={selected} field={field} onAfterSave={onAfterSave} />,
390 );
391 };
392
393 if (error) {
394 return <ErrorPill message={error} onDismiss={clearSelected} />;
395 }
396
397 return (
398 <ImagePickerMenu selected={selected}>
399 <MenuItem onClick={() => openFrame('browse')}>
400 {__('Pick from media library', 'extendify-local')}
401 </MenuItem>
402 <MenuItem onClick={() => openFrame('upload')}>
403 {__('Upload', 'extendify-local')}
404 </MenuItem>
405 <MenuItem onClick={() => openImageModal(AiImagePickerModal)}>
406 {__('Generate with AI', 'extendify-local')}
407 </MenuItem>
408 <MenuItem onClick={() => openImageModal(UnsplashImagePickerModal)}>
409 {__('Search for new image', 'extendify-local')}
410 </MenuItem>
411 </ImagePickerMenu>
412 );
413 };
414
415 // Re-anchor on scroll/resize. The menu always drops from the hover bar — the
416 // pill the user clicked — which `positionBar` (lib/hover-bar.js) places above
417 // OR below the image depending on viewport room, and which stays mounted for
418 // picker blocks. Reading the live bar keeps the menu pinned under the pill
419 // during scroll: hover-bar.js' own scroll listener is registered first, so it
420 // repositions the bar before this one reads it. Falls back to the image's top
421 // edge (where the bar would have sat) if the bar is somehow gone.
422 const ImagePickerMenu = ({ selected, children }) => {
423 const menuRef = useRef(null);
424 const compute = () => {
425 const bar = document
426 .querySelector('.extendify-quick-edit-bar')
427 ?.getBoundingClientRect();
428 // Anchor to the media figure for media-text so the menu lands over the
429 // image, not the whole block (which spans the text side too).
430 const image =
431 (selected.mediaEl ?? selected.el)?.getBoundingClientRect?.() ??
432 selected.anchorRect ??
433 null;
434 const anchor = bar ?? image;
435 if (!anchor) return { top: 0, left: 0 };
436 const MENU_W = 220;
437 const MENU_H = 180;
438 const GAP = 6;
439 // Center on the pill (itself centered on the picked element). Left-
440 // aligning to the element's left edge dropped the menu into dead space
441 // when the picked element was a viewport-wide cover (`anchor.left ≈ 0`).
442 let left = anchor.left + (anchor.width - MENU_W) / 2;
443 if (left + MENU_W > window.innerWidth - 4) {
444 left = window.innerWidth - MENU_W - 4;
445 }
446 if (left < 4) left = 4;
447 // Drop below the pill; flip above it when there isn't room below.
448 let top = bar ? bar.bottom + GAP : image.top;
449 if (top + MENU_H > window.innerHeight - 4) {
450 top = Math.max(4, (bar ? bar.top : image.bottom) - MENU_H - GAP);
451 }
452 if (top < 4) top = 4;
453 return { top, left };
454 };
455 const [pos, setPos] = useState(compute);
456 useEffect(() => {
457 const handler = () => setPos(compute());
458 window.addEventListener('scroll', handler, {
459 capture: true,
460 passive: true,
461 });
462 window.addEventListener('resize', handler);
463 return () => {
464 window.removeEventListener('scroll', handler, { capture: true });
465 window.removeEventListener('resize', handler);
466 };
467 }, [selected.el]);
468
469 // Standard menu pattern: focus the first item on open. The menu renders
470 // at the end of <body>, so Tab alone never reaches it — without this,
471 // keyboard users can't operate the picker at all.
472 useEffect(() => {
473 menuRef.current
474 ?.querySelector('[role="menuitem"]')
475 ?.focus({ preventScroll: true });
476 }, []);
477
478 const onKeyDown = (e) => {
479 if (e.key === 'Escape') {
480 // Restore focus to the picked block before the document-level
481 // escape handler (global-escape.js) clears the selection and
482 // unmounts this menu. Running here — a React handler on the
483 // quick-edit root — beats those document listeners to it.
484 selected.el?.focus?.({ preventScroll: true });
485 return;
486 }
487 if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
488 const items = [
489 ...(menuRef.current?.querySelectorAll('[role="menuitem"]') ?? []),
490 ];
491 if (!items.length) return;
492 e.preventDefault();
493 const cur = items.indexOf(document.activeElement);
494 const last = items.length - 1;
495 let next;
496 if (e.key === 'Home') next = 0;
497 else if (e.key === 'End') next = last;
498 else if (e.key === 'ArrowDown') next = cur < last ? cur + 1 : 0;
499 else next = cur > 0 ? cur - 1 : last;
500 items[next]?.focus({ preventScroll: true });
501 };
502
503 return (
504 <div
505 id="extendify-quick-edit-image-menu"
506 role="menu"
507 ref={menuRef}
508 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)]"
509 style={pos}
510 onKeyDown={onKeyDown}
511 >
512 {children}
513 </div>
514 );
515 };
516
517 const MenuItem = ({ onClick, children }) => (
518 <button
519 type="button"
520 role="menuitem"
521 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)]"
522 onMouseDown={(e) => e.preventDefault()}
523 onClick={onClick}
524 >
525 {children}
526 </button>
527 );
528
529 const UnsupportedNotice = ({ blockType }) => {
530 const clearSelected = useQuickEditStore((s) => s.clearSelected);
531 return (
532 <ErrorPill
533 message={`${__('No editor for this block type yet.', 'extendify-local')} (${blockType})`}
534 onDismiss={clearSelected}
535 />
536 );
537 };
538