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 / workflows / block-selector / components / UpdateBlockConfirm.jsx

UpdateBlockConfirm.jsx in Extendify 3.2.2, at src/Agent/workflows/block-selector/components/UpdateBlockConfirm.jsx

729 lines 24.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { SharedBlockNotice } from '@agent/components/SharedBlockNotice';
2 import { fetchBlockCodeById } from '@agent/lib/block-code';
3 import {
4 BLOCK_ID_SEL,
5 blockIdOf,
6 findBlockEl,
7 idAttrOf,
8 parseScopedId,
9 scopeOf,
10 } from '@agent/lib/block-el';
11 import { applyBlockPatch } from '@agent/lib/block-patch';
12 import { processCustomCss } from '@agent/lib/custom-css';
13 import { resolveDeleteTarget } from '@agent/lib/delete-target';
14 import { duplicateMarkup } from '@agent/lib/duplicate-block';
15 import { buildNewBlock } from '@agent/lib/insertable-blocks';
16 import { SETTING_TEXT_BLOCKS } from '@agent/lib/setting-text-blocks';
17 import { useQuickEditStore } from '@quick-edit/state/store';
18 import { patchVariantClasses } from '@shared/lib/variant-classes';
19 import apiFetch from '@wordpress/api-fetch';
20 import { parse } from '@wordpress/blocks';
21 import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
22 import { __ } from '@wordpress/i18n';
23
24 const dynamicClasses = ['is-style-ext-preset', 'is-style-outline'];
25 const wpBlockAttributeClasses =
26 /^has-([\w-]+-)?(background-color|color|font-size|gradient-background)$|^has-background$|^has-text-color$/;
27 // Carrying stale align/layout classes renders the preview with the old layout
28 const layoutEngineClasses =
29 /^align(full|wide|left|right|center)$|^is-layout-|^wp-container-|-is-layout-|^is-content-justification-|^is-(vertical|horizontal|nowrap|wrap)$|^has-global-padding$/;
30 const themeAnimationClasses = /^ext-animated?(-|$)/;
31
32 // Re-set ext-animated to prevent animating while patching
33 const pinThemeAnimations = (el) => {
34 for (const node of [el, ...el.querySelectorAll('.ext-animate')]) {
35 if (node.classList?.contains('ext-animate'))
36 node.dataset.extAnimated = 'true';
37 }
38 };
39 const PREVIEW_CSS_ATTR = 'data-extendify-preview-css';
40 const PART_SLUG_ATTR = 'data-extendify-part-slug';
41
42 // Without this a later op in the batch can't tell the replacement from the
43 // same-numbered block in another part.
44 const carryPartSlug = (from, to) => {
45 const slug = from?.getAttribute?.(PART_SLUG_ATTR);
46 if (slug) to?.setAttribute?.(PART_SLUG_ATTR, slug);
47 };
48
49 const cssOf = (blockCode) =>
50 parse(blockCode)[0]?.attributes?.style?.css || null;
51
52 // The wp-container-* layout rules enqueue page-side on a full render only, so
53 // the fragment ships them and the preview injects them, tagged for teardown.
54 const injectPreviewStylesheet = (blockId, cssText) => {
55 if (!cssText) return;
56 const style = document.createElement('style');
57 style.setAttribute(PREVIEW_CSS_ATTR, blockId);
58 style.textContent = cssText;
59 document.head.appendChild(style);
60 };
61
62 // The block fragment ships without style.css's server rule, so inject it here,
63 // tagged for teardown. No rule means WP discards this CSS too — show nothing.
64 const injectPreviewCss = (el, blockId, css) => {
65 const cls = `ext-preview-css-${blockId}`;
66 const rule = processCustomCss(css, `.${cls}`);
67 if (!rule) return;
68 el.classList.add(cls);
69 const style = document.createElement('style');
70 style.setAttribute(PREVIEW_CSS_ATTR, blockId);
71 style.textContent = rule;
72 document.head.appendChild(style);
73 };
74
75 // Swap the rendered preview in for the live element. Returns the detached
76 // original (restored on cancel), or null when the target isn't on the page.
77 const previewBlock = async (blockId, newContent, css, scope) => {
78 const { content, styles } = await apiFetch({
79 path: '/extendify/v1/agent/get-block-html',
80 method: 'POST',
81 data: { blockCode: newContent },
82 });
83 const el = findBlockEl(blockId, document, scope);
84 if (!el) return null;
85 injectPreviewStylesheet(blockId, styles);
86
87 const patched = patchVariantClasses(
88 content,
89 el.cloneNode(true),
90 dynamicClasses,
91 );
92 const template = document.createElement('template');
93 template.innerHTML = patched || '<div style="display:none"></div>';
94 const newEl = template.content.firstElementChild;
95 if (!newEl) return null;
96
97 // Later ops anchor by id — the replacement and its children keep theirs;
98 // an attribute edit preserves child structure, so ids map by position.
99 newEl.setAttribute(idAttrOf(el), blockId);
100 carryPartSlug(el, newEl);
101 for (const tagged of el.querySelectorAll(BLOCK_ID_SEL)) {
102 const path = [];
103 for (let node = tagged; node !== el; node = node.parentElement) {
104 if (!node.parentElement) break;
105 path.unshift([...node.parentElement.children].indexOf(node));
106 }
107 const match = path.reduce((node, i) => node?.children?.[i], newEl);
108 match?.setAttribute(idAttrOf(tagged), blockIdOf(tagged));
109 carryPartSlug(tagged, match);
110 }
111 const newElClasses = new Set(newEl.classList);
112 el.classList.forEach((className) => {
113 if (newElClasses.has(className)) return;
114 if (wpBlockAttributeClasses.test(className)) return;
115 if (layoutEngineClasses.test(className)) return;
116 if (themeAnimationClasses.test(className)) return;
117 newEl.classList.add(className);
118 });
119 // The custom-CSS hash class points at a stale/absent server rule — drop it;
120 // injectPreviewCss applies the current css.
121 for (const className of [...newEl.classList]) {
122 if (
123 className === 'has-custom-css' ||
124 className.startsWith('wp-custom-css-')
125 )
126 newEl.classList.remove(className);
127 }
128 if (css) injectPreviewCss(newEl, blockId, css);
129 newEl.setAttribute('data-extendify-temp-replacement', blockId);
130 // ext-animate--on sets opacity:0 and won't re-run on a replaced node, so the
131 // preview would stay invisible — strip it.
132 for (const node of [newEl, ...newEl.querySelectorAll('.ext-animate--on')]) {
133 node.classList.remove('ext-animate--on');
134 }
135 el.parentNode.insertBefore(newEl, el.nextSibling);
136 el.parentNode.removeChild(el);
137 return el;
138 };
139
140 // A hidden element still counts as a sibling and gives the next block the layout's gap.
141 const slots = new Map();
142 const holdSlot = (blockId, parent, before) => {
143 const slot = document.createComment('');
144 slots.set(blockId, slot);
145 parent.insertBefore(slot, before);
146 };
147
148 // Relocate the live node; a slot holds its old place so undo can put it back.
149 const previewMove = ({ blockId, targetId, position }, scope) => {
150 const el = findBlockEl(blockId, document, scope);
151 const target = findBlockEl(targetId, document, scope);
152 if (!el || !target) return null;
153 holdSlot(blockId, el.parentNode, el);
154 pinThemeAnimations(el);
155 target.parentNode.insertBefore(
156 el,
157 position === 'after' ? target.nextSibling : target,
158 );
159 return el;
160 };
161
162 const renderAddedEl = async (block, index) => {
163 const { content, styles } = await apiFetch({
164 path: '/extendify/v1/agent/get-block-html',
165 method: 'POST',
166 data: { blockCode: block },
167 });
168 if (!content) return null;
169 injectPreviewStylesheet(`add-${index}`, styles);
170 const template = document.createElement('template');
171 template.innerHTML = content;
172 const newEl = template.content.firstElementChild;
173 if (!newEl) return null;
174 newEl.setAttribute('data-extendify-temp-addition', '');
175 const css = cssOf(block);
176 if (css) injectPreviewCss(newEl, `add-${index}`, css);
177 for (const node of [newEl, ...newEl.querySelectorAll('.ext-animate--on')]) {
178 node.classList.remove('ext-animate--on');
179 }
180 return newEl;
181 };
182
183 // Render the new block and slot it next to its anchor. Nothing detaches —
184 // returns true so the caller counts it rendered; undo just removes the node.
185 const previewAdd = async ({ anchorId, position, block }, index, scope) => {
186 const anchor = findBlockEl(anchorId, document, scope);
187 if (!anchor) return null;
188 const newEl = await renderAddedEl(block, index);
189 if (!newEl) return null;
190 anchor.parentNode.insertBefore(
191 newEl,
192 position === 'after' ? anchor.nextSibling : anchor,
193 );
194 return true;
195 };
196
197 const TEMP_CLASSES_ATTR = 'data-extendify-temp-classes';
198 const SUBMENU_ITEM_CLASSES = [
199 'has-child',
200 'open-on-hover-click',
201 'wp-block-navigation-submenu',
202 ];
203 const SUBMENU_OPEN_CLASS = 'extendify-preview-submenu-open';
204 // WordPress keeps a submenu hidden until hover, which would hide the new link.
205 const SUBMENU_OPEN_CSS = `.${SUBMENU_OPEN_CLASS} > .wp-block-navigation__submenu-container { visibility: visible !important; opacity: 1 !important; height: auto !important; width: auto !important; min-width: 200px; overflow: visible !important; }`;
206
207 const addTempClasses = (el, classes) => {
208 const added = classes.filter((name) => !el.classList.contains(name));
209 if (!added.length) return;
210 el.classList.add(...added);
211 const earlier = el.getAttribute(TEMP_CLASSES_ATTR);
212 el.setAttribute(
213 TEMP_CLASSES_ATTR,
214 [earlier, ...added].filter(Boolean).join(' '),
215 );
216 };
217
218 const submenuToggle = (label) => {
219 const button = document.createElement('button');
220 button.className =
221 'wp-block-navigation__submenu-icon wp-block-navigation-submenu__toggle';
222 button.setAttribute('aria-label', `${label} submenu`);
223 button.setAttribute('aria-expanded', 'true');
224 button.innerHTML =
225 '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true" focusable="false"><path d="M1.50002 4L6.00002 8L10.5 4" stroke-width="1.5"></path></svg>';
226 return button;
227 };
228
229 // Mirrors nestInMenuItem with the markup WordPress renders for a submenu, shown open.
230 const previewNest = async ({ anchorId, block }, index, scope) => {
231 const anchor = findBlockEl(anchorId, document, scope);
232 if (!anchor) return null;
233 const newEl = await renderAddedEl(block, index);
234 if (!newEl) return null;
235 const existing = anchor.querySelector(
236 ':scope > .wp-block-navigation__submenu-container',
237 );
238 const list = existing ?? document.createElement('ul');
239 if (!existing) {
240 // A sibling dropdown carries the menu's submenu colours; copy them.
241 list.className =
242 anchor
243 .closest('.wp-block-navigation')
244 ?.querySelector('.wp-block-navigation__submenu-container')?.className ??
245 'wp-block-navigation__submenu-container wp-block-navigation-submenu';
246 const toggle = submenuToggle(anchor.textContent.trim());
247 for (const el of [toggle, list]) {
248 el.setAttribute('data-extendify-temp-addition', '');
249 }
250 addTempClasses(anchor, SUBMENU_ITEM_CLASSES);
251 anchor.append(toggle, list);
252 }
253 list.appendChild(newEl);
254 addTempClasses(anchor, [SUBMENU_OPEN_CLASS]);
255 injectPreviewStylesheet(`nest-${index}`, SUBMENU_OPEN_CSS);
256 return true;
257 };
258
259 // Mirror the server's column routing off the DOM (spliceColumn owns the why).
260 const previewColumnAdd = async (
261 { anchorId, position, block },
262 index,
263 wrappers,
264 scope,
265 ) => {
266 const anchor = findBlockEl(anchorId, document, scope);
267 if (!anchor) return null;
268 if (anchor.classList.contains('wp-block-column')) {
269 return previewAdd({ anchorId, position, block }, index, scope);
270 }
271 const shared = wrappers.get(`${anchorId}:${position}`);
272 if (shared) {
273 const newEl = await renderAddedEl(block, index);
274 if (!newEl) return null;
275 shared.appendChild(newEl);
276 return true;
277 }
278 const newEl = await renderAddedEl(
279 `<!-- wp:columns --><div class="wp-block-columns">${block}</div><!-- /wp:columns -->`,
280 index,
281 );
282 if (!newEl) return null;
283 anchor.parentNode.insertBefore(
284 newEl,
285 position === 'after' ? anchor.nextSibling : anchor,
286 );
287 wrappers.set(`${anchorId}:${position}`, newEl);
288 return true;
289 };
290
291 // Keyed by the model-facing container word — the code supplies the
292 // core/columns parent a bare column needs, mirroring the server templates.
293 const WRAP_SHELLS = {
294 'core/column':
295 '<!-- wp:columns --><div class="wp-block-columns"><!-- wp:column --><div class="wp-block-column"></div><!-- /wp:column --></div><!-- /wp:columns -->',
296 'core/group':
297 '<!-- wp:group {"layout":{"type":"constrained"}} --><div class="wp-block-group"></div><!-- /wp:group -->',
298 };
299
300 // The relocated node keeps its block id, so a later add in the batch can
301 // still anchor to it; a slot holds its old place for undo.
302 const previewWrap = async ({ blockId, container }, wrappers, scope) => {
303 const el = findBlockEl(blockId, document, scope);
304 const shellCode = WRAP_SHELLS[container];
305 if (!el || !shellCode) return null;
306 // Two column wraps in one batch share one section, mirroring the save.
307 const sharedShell =
308 container === 'core/column' ? wrappers.get('wrap-shell') : null;
309 if (sharedShell && !el.contains(sharedShell)) {
310 holdSlot(blockId, el.parentNode, el);
311 const column = document.createElement('div');
312 column.className = 'wp-block-column';
313 pinThemeAnimations(el);
314 column.appendChild(el);
315 sharedShell.appendChild(column);
316 wrappers.set(`${blockId}:after`, sharedShell);
317 wrappers.set(`${blockId}:before`, sharedShell);
318 return el;
319 }
320 const { content } = await apiFetch({
321 path: '/extendify/v1/agent/get-block-html',
322 method: 'POST',
323 data: { blockCode: shellCode },
324 });
325 const template = document.createElement('template');
326 template.innerHTML = content ?? '';
327 const shell = template.content.firstElementChild;
328 if (!shell) return null;
329 shell.setAttribute('data-extendify-temp-addition', '');
330 if (container === 'core/column') {
331 // A later column add anchored to the wrapped block joins this shell.
332 wrappers.set(`${blockId}:after`, shell);
333 wrappers.set(`${blockId}:before`, shell);
334 wrappers.set('wrap-shell', shell);
335 }
336 el.parentNode.insertBefore(shell, el);
337 holdSlot(blockId, el.parentNode, el);
338 pinThemeAnimations(el);
339 (shell.querySelector('.wp-block-column') ?? shell).appendChild(el);
340 return el;
341 };
342
343 // A fresh render numbers style variants the page has no CSS for.
344 const renderCopyOf = async (el, markup, index) => {
345 const { content, styles } = await apiFetch({
346 path: '/extendify/v1/agent/get-block-html',
347 method: 'POST',
348 data: { blockCode: markup },
349 });
350 injectPreviewStylesheet(`copy-${index}`, styles);
351 const template = document.createElement('template');
352 template.innerHTML =
353 patchVariantClasses(content ?? '', el.cloneNode(true), dynamicClasses) ||
354 '';
355 return template.content.firstElementChild;
356 };
357
358 // Id-less, or a later op in the batch could land on the copy.
359 const previewDuplicate = async (
360 { blockId, targetId, position, markup },
361 index,
362 scope,
363 ) => {
364 const el = findBlockEl(blockId, document, scope);
365 const anchor = targetId ? findBlockEl(targetId, document, scope) : el;
366 if (!el || !anchor) return null;
367 const copy = markup
368 ? await renderCopyOf(el, markup, index)
369 : el.cloneNode(true);
370 if (!copy) return null;
371 for (const node of [copy, ...copy.querySelectorAll(BLOCK_ID_SEL)]) {
372 node.removeAttribute(idAttrOf(node));
373 }
374 for (const node of [copy, ...copy.querySelectorAll('.ext-animate--on')]) {
375 node.classList.remove('ext-animate--on');
376 }
377 copy.setAttribute('data-extendify-temp-addition', '');
378 anchor.parentNode.insertBefore(
379 copy,
380 position === 'before' ? anchor : anchor.nextSibling,
381 );
382 return true;
383 };
384
385 // Re-rendering the markup would preview the old text — the option holds it.
386 const previewSettingText = (blockId, text, scope) => {
387 const el = findBlockEl(blockId, document, scope);
388 if (!el) return null;
389 const preview = el.cloneNode(true);
390 const textNode = preview.querySelector('a') ?? preview;
391 textNode.textContent = text;
392 preview.setAttribute('data-extendify-temp-replacement', blockId);
393 el.parentNode.insertBefore(preview, el.nextSibling);
394 el.parentNode.removeChild(el);
395 return el;
396 };
397
398 // Remove the target, leaving a slot so cancel restores it like a swapped preview.
399 const previewDelete = (blockId, scope) => {
400 const el = findBlockEl(blockId, document, scope);
401 if (!el) return null;
402 holdSlot(blockId, el.parentNode, el);
403 el.parentNode.removeChild(el);
404 return el;
405 };
406
407 // The DOM attribute and the save both carry the bare id, not the scoped one.
408 const unscope = (operation, fallback) => {
409 if (!operation) return { operation, scope: fallback };
410 const next = { ...operation };
411 let partSlug = null;
412 for (const field of ['blockId', 'anchorId', 'targetId']) {
413 if (next[field] == null) continue;
414 const parsed = parseScopedId(next[field]);
415 if (parsed.partSlug) partSlug = parsed.partSlug;
416 next[field] = parsed.blockId;
417 }
418 if (Array.isArray(next.texts)) {
419 next.texts = next.texts.map((entry) => ({
420 ...entry,
421 blockId: parseScopedId(entry.blockId).blockId,
422 }));
423 }
424 return { operation: next, scope: partSlug ? { partSlug } : fallback };
425 };
426
427 // Each target pairs the operation that saves with the preview that shows it.
428 // Delete and move ids resolve off the pristine DOM before the preview
429 // detaches anything, so preview + save agree on wrapper targets.
430 const buildOperationTarget = async (
431 rawOperation,
432 block,
433 postId,
434 index,
435 wrappers,
436 ) => {
437 const { operation, scope } = unscope(rawOperation, scopeOf(block));
438 if (operation?.op === 'add') {
439 // Same builder the save-time tool uses, so preview and save agree.
440 const markup = buildNewBlock(
441 operation.blockType,
442 operation.patch,
443 operation.clear ?? [],
444 window.extAgentData?.context?.presetSlugs ?? {},
445 );
446 return {
447 operation,
448 preview: () => {
449 if (!markup) return null;
450 const withMarkup = { ...operation, block: markup };
451 if (operation.position === 'inside') {
452 return previewNest(withMarkup, index, scope);
453 }
454 return operation.blockType === 'core/column'
455 ? previewColumnAdd(withMarkup, index, wrappers, scope)
456 : previewAdd(withMarkup, index, scope);
457 },
458 };
459 }
460 if (operation?.op === 'wrap') {
461 // Wrapping just a lone child nests the new container inside its old wrapper.
462 const resolved = {
463 ...operation,
464 blockId: resolveDeleteTarget(operation.blockId, scope),
465 };
466 return {
467 operation: resolved,
468 preview: () => previewWrap(resolved, wrappers, scope),
469 };
470 }
471 if (operation?.op === 'duplicate') {
472 const markup = await duplicateMarkup(operation, block?.source, postId);
473 return {
474 operation,
475 preview: () => previewDuplicate({ ...operation, markup }, index, scope),
476 };
477 }
478 if (operation?.op === 'move') {
479 const resolved = {
480 ...operation,
481 blockId: resolveDeleteTarget(operation.blockId, scope),
482 };
483 return { operation: resolved, preview: () => previewMove(resolved, scope) };
484 }
485 if (operation?.op === 'delete') {
486 const resolved = {
487 ...operation,
488 blockId: resolveDeleteTarget(operation.blockId, scope),
489 };
490 return {
491 operation: resolved,
492 preview: () => previewDelete(resolved.blockId, scope),
493 };
494 }
495 // Image swaps live in ReplaceImageConfirm; a stray one here saves as no-change.
496 if (operation?.op === 'replace-image')
497 return { operation, preview: () => null };
498 const { blockId, patch, clear } = operation ?? {};
499 if (SETTING_TEXT_BLOCKS[block?.blockType] && patch?.text != null) {
500 return {
501 operation,
502 preview: () => previewSettingText(blockId, patch.text, scope),
503 };
504 }
505 const newContent = applyBlockPatch(
506 await fetchBlockCodeById(blockId, block?.source, postId),
507 patch,
508 clear ?? [],
509 window.extAgentData?.context?.presetSlugs ?? {},
510 );
511 return {
512 operation,
513 preview: () =>
514 newContent
515 ? previewBlock(blockId, newContent, cssOf(newContent), scope)
516 : null,
517 };
518 };
519
520 // block-general workflows still send a whole-block newContent replace.
521 const buildLegacyTarget = (inputs, block) => ({
522 operation: null,
523 preview: () =>
524 inputs.newContent
525 ? previewBlock(
526 block?.id,
527 inputs.newContent,
528 cssOf(inputs.newContent),
529 scopeOf(block),
530 )
531 : null,
532 });
533
534 export const UpdateBlockConfirm = ({
535 inputs,
536 onConfirm,
537 onCancel,
538 onRetry,
539 }) => {
540 const block = useQuickEditStore((s) => s.agentBlock);
541 const [loading, setLoading] = useState(true);
542 const detached = useRef([]);
543 // What actually saves — delete rewrites this to the DOM-resolved wrapper ids.
544 const saveData = useRef(inputs);
545
546 const operations = Array.isArray(inputs.operations)
547 ? inputs.operations
548 : null;
549
550 const undoBlockChange = useCallback(() => {
551 for (const original of detached.current) {
552 const id = blockIdOf(original);
553 const replacement =
554 document.querySelector(
555 `[data-extendify-temp-replacement="${CSS.escape(id)}"]`,
556 ) ?? slots.get(id);
557 slots.delete(id);
558 pinThemeAnimations(original);
559 replacement?.parentNode?.insertBefore(original, replacement);
560 replacement?.remove();
561 }
562 for (const added of document.querySelectorAll(
563 '[data-extendify-temp-addition]',
564 ))
565 added.remove();
566 for (const dressed of document.querySelectorAll(`[${TEMP_CLASSES_ATTR}]`)) {
567 dressed.classList.remove(
568 ...dressed.getAttribute(TEMP_CLASSES_ATTR).split(' '),
569 );
570 dressed.removeAttribute(TEMP_CLASSES_ATTR);
571 }
572 for (const style of document.querySelectorAll(`style[${PREVIEW_CSS_ATTR}]`))
573 style.remove();
574 detached.current = [];
575 }, []);
576
577 const confirmed = useRef(false);
578 const unmounted = useRef(false);
579 useEffect(() => {
580 return () => {
581 unmounted.current = true;
582 if (!confirmed.current) undoBlockChange();
583 };
584 }, [undoBlockChange]);
585
586 const handleConfirm = async () => {
587 confirmed.current = true;
588 await onConfirm({ data: saveData.current, shouldRefreshPage: true });
589 };
590
591 const handleRetry = useCallback(() => {
592 undoBlockChange();
593 onRetry();
594 }, [undoBlockChange, onRetry]);
595
596 // Re-renders (the staged block changes identity on page clicks) must not
597 // inject the preview again and clobber the undo list.
598 const previewed = useRef(false);
599 useEffect(() => {
600 if (previewed.current) return;
601 previewed.current = true;
602 const run = async () => {
603 const postId = window.extAgentData?.context?.postId;
604 const operations = Array.isArray(inputs.operations)
605 ? inputs.operations
606 : null;
607 const wrappers = new Map();
608 const targets = operations
609 ? await Promise.all(
610 operations.map((operation, index) =>
611 buildOperationTarget(operation, block, postId, index, wrappers),
612 ),
613 )
614 : [buildLegacyTarget(inputs, block)];
615 if (operations)
616 saveData.current = {
617 ...inputs,
618 operations: targets.map(({ operation }) => operation),
619 };
620
621 const originals = [];
622 let rendered = 0;
623 for (const target of targets) {
624 const original = await target.preview();
625 if (!original) continue;
626 rendered++;
627 // An add preview has no original to restore — only count it.
628 if (original !== true) originals.push(original);
629 }
630 detached.current = originals;
631 // An unmount mid-preview already ran its undo; skipping this leaves the preview stuck.
632 if (unmounted.current) return undoBlockChange();
633 // Nothing rendered means none of the target blocks are on the page.
634 if (!rendered) return onCancel();
635 setLoading(false);
636 };
637 run();
638 }, [block, inputs, onCancel, operations, undoBlockChange]);
639
640 if (loading)
641 return (
642 <Wrapper>
643 <Content>{__('Loading...', 'extendify-local')}</Content>
644 </Wrapper>
645 );
646
647 const onlyOp = (op) =>
648 Array.isArray(inputs.operations) &&
649 inputs.operations.every((operation) => operation?.op === op);
650 const message = onlyOp('delete')
651 ? __(
652 'The agent will remove the selected block. Please review and confirm.',
653 'extendify-local',
654 )
655 : onlyOp('move')
656 ? __(
657 'The agent has rearranged the blocks in the browser. Please review and confirm.',
658 'extendify-local',
659 )
660 : onlyOp('add')
661 ? __(
662 'The agent has added the new block in the browser. Please review and confirm.',
663 'extendify-local',
664 )
665 : onlyOp('wrap')
666 ? __(
667 'The agent has placed the block in its new container in the browser. Please review and confirm.',
668 'extendify-local',
669 )
670 : onlyOp('duplicate')
671 ? __(
672 'The agent has copied the block in the browser. Please review and confirm.',
673 'extendify-local',
674 )
675 : __(
676 'The agent has made the changes in the browser. Please review and confirm.',
677 'extendify-local',
678 );
679
680 return (
681 <Wrapper>
682 <Content>
683 <p className="m-0 p-0 text-sm text-gray-900">{message}</p>
684 <SharedBlockNotice
685 blockIds={[
686 ...(operations ?? []).map((operation) => operation?.blockId),
687 block?.id,
688 ]}
689 />
690 </Content>
691 <div className="flex flex-wrap justify-start gap-2 p-3">
692 <button
693 type="button"
694 className="flex-1 rounded-sm border border-gray-500 bg-white p-2 text-sm text-gray-900"
695 onClick={onCancel}
696 >
697 {__('Cancel', 'extendify-local')}
698 </button>
699 <button
700 type="button"
701 className="flex-1 rounded-sm border border-gray-500 bg-white p-2 text-sm text-gray-900"
702 onClick={handleRetry}
703 >
704 {__('Try Again', 'extendify-local')}
705 </button>
706 <button
707 type="button"
708 className="flex-1 rounded-sm border border-design-main bg-design-main p-2 text-sm text-white"
709 onClick={handleConfirm}
710 >
711 {__('Save', 'extendify-local')}
712 </button>
713 </div>
714 </Wrapper>
715 );
716 };
717
718 const Wrapper = ({ children }) => (
719 <div className="mb-4 ms-2 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50">
720 {children}
721 </div>
722 );
723
724 const Content = ({ children }) => (
725 <div className="rounded-lg border-b border-gray-300 bg-white">
726 <div className="p-3">{children}</div>
727 </div>
728 );
729