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
← All changes | src/Agent/workflows/block-selector/components/UpdateBlockConfirm.jsx +639 -61 3.1.3 → 3.2.2 View file →
@@ -1,12 +1,537 @@
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';
1 17 import { useQuickEditStore } from '@quick-edit/state/store';
2 18 import { patchVariantClasses } from '@shared/lib/variant-classes';
3 19 import apiFetch from '@wordpress/api-fetch';
20 +import { parse } from '@wordpress/blocks';
4 21 import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
5 22 import { __ } from '@wordpress/i18n';
6 23
7 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?(-|$)/;
8 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 +
9 534 export const UpdateBlockConfirm = ({
10 535 inputs,
11 536 onConfirm,
12 537 onCancel,
@@ -13,29 +538,55 @@
13 538 onRetry,
14 539 }) => {
15 540 const block = useQuickEditStore((s) => s.agentBlock);
16 541 const [loading, setLoading] = useState(true);
17 - const detachedEl = useRef(null);
542 + const detached = useRef([]);
543 + // What actually saves — delete rewrites this to the DOM-resolved wrapper ids.
544 + const saveData = useRef(inputs);
18 545
546 + const operations = Array.isArray(inputs.operations)
547 + ? inputs.operations
548 + : null;
549 +
19 550 const undoBlockChange = useCallback(() => {
20 - const el = document.querySelector('[data-extendify-temp-replacement]');
21 - if (detachedEl.current) {
22 - el?.parentNode?.insertBefore(detachedEl.current, el);
23 - detachedEl.current = null;
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();
24 561 }
25 - if (el) el.remove();
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 = [];
26 575 }, []);
27 576
28 577 const confirmed = useRef(false);
578 + const unmounted = useRef(false);
29 579 useEffect(() => {
30 580 return () => {
581 + unmounted.current = true;
31 582 if (!confirmed.current) undoBlockChange();
32 583 };
33 - }, []);
584 + }, [undoBlockChange]);
34 585
35 586 const handleConfirm = async () => {
36 587 confirmed.current = true;
37 - await onConfirm({ data: inputs, shouldRefreshPage: true });
588 + await onConfirm({ data: saveData.current, shouldRefreshPage: true });
38 589 };
39 590
40 591 const handleRetry = useCallback(() => {
41 592 undoBlockChange();
@@ -41,58 +592,51 @@
41 592 undoBlockChange();
42 593 onRetry();
43 594 }, [undoBlockChange, onRetry]);
44 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);
45 599 useEffect(() => {
46 - apiFetch({
47 - path: '/extendify/v1/agent/get-block-html',
48 - method: 'POST',
49 - data: { blockCode: inputs.newContent },
50 - }).then(({ content }) => {
51 - // Remove the highlighter
52 - window.dispatchEvent(new Event('extendify-agent:remove-block-highlight'));
53 - // hide the block
54 - const el = document.querySelector(
55 - `[data-extendify-agent-block-id="${block?.id}"]`,
56 - );
57 - // TODO: work out a way to propagate an error here
58 - if (!el && !detachedEl.current) return onCancel();
59 - if (detachedEl.current) return; // already done
60 - detachedEl.current = el;
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 + };
61 620
62 - const patched = patchVariantClasses(
63 - content,
64 - el.cloneNode(true),
65 - dynamicClasses,
66 - );
67 -
68 - const template = document.createElement('template');
69 - template.innerHTML = patched || '<div style="display:none"></div>';
70 - const newEl = template.content.firstElementChild;
71 - if (!newEl) return onCancel();
72 - const wpBlockAttributeClasses =
73 - /^has-([\w-]+-)?(background-color|color|font-size|gradient-background)$|^has-background$|^has-text-color$/;
74 - const newElClasses = new Set(newEl.classList);
75 - el.classList.forEach((className) => {
76 - if (newElClasses.has(className)) return;
77 - if (wpBlockAttributeClasses.test(className)) return;
78 - newEl.classList.add(className);
79 - });
80 - newEl.setAttribute('data-extendify-temp-replacement', true);
81 - // Strip animation classes so the preview is visible immediately.
82 - // The ext-animate--on class sets opacity:0 as initial state, but
83 - // the animation JS won't re-run on replaced DOM elements.
84 - for (const node of [
85 - newEl,
86 - ...newEl.querySelectorAll('.ext-animate--on'),
87 - ]) {
88 - node.classList.remove('ext-animate--on');
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);
89 629 }
90 - el.parentNode.insertBefore(newEl, el.nextSibling);
91 - el.parentNode?.removeChild(el);
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();
92 635 setLoading(false);
93 - });
94 - }, [block, inputs, onCancel]);
636 + };
637 + run();
638 + }, [block, inputs, onCancel, operations, undoBlockChange]);
95 639
96 640 if (loading)
97 641 return (
98 642 <Wrapper>
@@ -99,17 +643,51 @@
99 643 <Content>{__('Loading...', 'extendify-local')}</Content>
100 644 </Wrapper>
101 645 );
102 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 +
103 680 return (
104 681 <Wrapper>
105 682 <Content>
106 - <p className="m-0 p-0 text-sm text-gray-900">
107 - {__(
108 - 'The agent has made the changes in the browser. Please review and confirm.',
109 - 'extendify-local',
110 - )}
111 - </p>
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 + />
112 690 </Content>
113 691 <div className="flex flex-wrap justify-start gap-2 p-3">
114 692 <button
115 693 type="button"
@@ -137,9 +715,9 @@
137 715 );
138 716 };
139 717
140 718 const Wrapper = ({ children }) => (
141 - <div className="mb-4 ml-10 mr-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50 rtl:ml-2 rtl:mr-10">
719 + <div className="mb-4 ms-2 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50">
142 720 {children}
143 721 </div>
144 722 );
145 723