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 +164 -29 3.2.0 → 3.2.2 View file →
@@ -10,8 +10,9 @@
10 10 } from '@agent/lib/block-el';
11 11 import { applyBlockPatch } from '@agent/lib/block-patch';
12 12 import { processCustomCss } from '@agent/lib/custom-css';
13 13 import { resolveDeleteTarget } from '@agent/lib/delete-target';
14 +import { duplicateMarkup } from '@agent/lib/duplicate-block';
14 15 import { buildNewBlock } from '@agent/lib/insertable-blocks';
15 16 import { SETTING_TEXT_BLOCKS } from '@agent/lib/setting-text-blocks';
16 17 import { useQuickEditStore } from '@quick-edit/state/store';
17 18 import { patchVariantClasses } from '@shared/lib/variant-classes';
@@ -135,17 +136,22 @@
135 136 el.parentNode.removeChild(el);
136 137 return el;
137 138 };
138 139
139 -// Relocate the live node; a hidden marker holds its old slot so undo can put it back.
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.
140 149 const previewMove = ({ blockId, targetId, position }, scope) => {
141 150 const el = findBlockEl(blockId, document, scope);
142 151 const target = findBlockEl(targetId, document, scope);
143 152 if (!el || !target) return null;
144 - const marker = document.createElement('div');
145 - marker.style.display = 'none';
146 - marker.setAttribute('data-extendify-temp-replacement', blockId);
147 - el.parentNode.insertBefore(marker, el);
153 + holdSlot(blockId, el.parentNode, el);
148 154 pinThemeAnimations(el);
149 155 target.parentNode.insertBefore(
150 156 el,
151 157 position === 'after' ? target.nextSibling : target,
@@ -187,8 +193,70 @@
187 193 );
188 194 return true;
189 195 };
190 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 +
191 259 // Mirror the server's column routing off the DOM (spliceColumn owns the why).
192 260 const previewColumnAdd = async (
193 261 { anchorId, position, block },
194 262 index,
@@ -229,9 +297,9 @@
229 297 '<!-- wp:group {"layout":{"type":"constrained"}} --><div class="wp-block-group"></div><!-- /wp:group -->',
230 298 };
231 299
232 300 // The relocated node keeps its block id, so a later add in the batch can
233 -// still anchor to it; a hidden marker holds its old slot for undo.
301 +// still anchor to it; a slot holds its old place for undo.
234 302 const previewWrap = async ({ blockId, container }, wrappers, scope) => {
235 303 const el = findBlockEl(blockId, document, scope);
236 304 const shellCode = WRAP_SHELLS[container];
237 305 if (!el || !shellCode) return null;
@@ -238,12 +306,9 @@
238 306 // Two column wraps in one batch share one section, mirroring the save.
239 307 const sharedShell =
240 308 container === 'core/column' ? wrappers.get('wrap-shell') : null;
241 309 if (sharedShell && !el.contains(sharedShell)) {
242 - const marker = document.createElement('div');
243 - marker.style.display = 'none';
244 - marker.setAttribute('data-extendify-temp-replacement', blockId);
245 - el.parentNode.insertBefore(marker, el);
310 + holdSlot(blockId, el.parentNode, el);
246 311 const column = document.createElement('div');
247 312 column.className = 'wp-block-column';
248 313 pinThemeAnimations(el);
249 314 column.appendChild(el);
@@ -267,18 +332,57 @@
267 332 wrappers.set(`${blockId}:after`, shell);
268 333 wrappers.set(`${blockId}:before`, shell);
269 334 wrappers.set('wrap-shell', shell);
270 335 }
271 - const marker = document.createElement('div');
272 - marker.style.display = 'none';
273 - marker.setAttribute('data-extendify-temp-replacement', blockId);
274 - el.parentNode.insertBefore(marker, el);
275 - el.parentNode.insertBefore(shell, marker);
336 + el.parentNode.insertBefore(shell, el);
337 + holdSlot(blockId, el.parentNode, el);
276 338 pinThemeAnimations(el);
277 339 (shell.querySelector('.wp-block-column') ?? shell).appendChild(el);
278 340 return el;
279 341 };
280 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 +
281 385 // Re-rendering the markup would preview the old text — the option holds it.
282 386 const previewSettingText = (blockId, text, scope) => {
283 387 const el = findBlockEl(blockId, document, scope);
284 388 if (!el) return null;
@@ -290,16 +394,13 @@
290 394 el.parentNode.removeChild(el);
291 395 return el;
292 396 };
293 397
294 -// Remove the target, leaving a hidden marker so cancel restores it like a swapped preview.
398 +// Remove the target, leaving a slot so cancel restores it like a swapped preview.
295 399 const previewDelete = (blockId, scope) => {
296 400 const el = findBlockEl(blockId, document, scope);
297 401 if (!el) return null;
298 - const marker = document.createElement('div');
299 - marker.style.display = 'none';
300 - marker.setAttribute('data-extendify-temp-replacement', blockId);
301 - el.parentNode.insertBefore(marker, el.nextSibling);
402 + holdSlot(blockId, el.parentNode, el);
302 403 el.parentNode.removeChild(el);
303 404 return el;
304 405 };
305 406
@@ -313,8 +414,14 @@
313 414 const parsed = parseScopedId(next[field]);
314 415 if (parsed.partSlug) partSlug = parsed.partSlug;
315 416 next[field] = parsed.blockId;
316 417 }
418 + if (Array.isArray(next.texts)) {
419 + next.texts = next.texts.map((entry) => ({
420 + ...entry,
421 + blockId: parseScopedId(entry.blockId).blockId,
422 + }));
423 + }
317 424 return { operation: next, scope: partSlug ? { partSlug } : fallback };
318 425 };
319 426
320 427 // Each target pairs the operation that saves with the preview that shows it.
@@ -340,8 +447,11 @@
340 447 operation,
341 448 preview: () => {
342 449 if (!markup) return null;
343 450 const withMarkup = { ...operation, block: markup };
451 + if (operation.position === 'inside') {
452 + return previewNest(withMarkup, index, scope);
453 + }
344 454 return operation.blockType === 'core/column'
345 455 ? previewColumnAdd(withMarkup, index, wrappers, scope)
346 456 : previewAdd(withMarkup, index, scope);
347 457 },
@@ -357,8 +467,15 @@
357 467 operation: resolved,
358 468 preview: () => previewWrap(resolved, wrappers, scope),
359 469 };
360 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 + }
361 478 if (operation?.op === 'move') {
362 479 const resolved = {
363 480 ...operation,
364 481 blockId: resolveDeleteTarget(operation.blockId, scope),
@@ -431,11 +548,14 @@
431 548 : null;
432 549
433 550 const undoBlockChange = useCallback(() => {
434 551 for (const original of detached.current) {
435 - const replacement = document.querySelector(
436 - `[data-extendify-temp-replacement="${CSS.escape(blockIdOf(original))}"]`,
437 - );
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);
438 558 pinThemeAnimations(original);
439 559 replacement?.parentNode?.insertBefore(original, replacement);
440 560 replacement?.remove();
441 561 }
@@ -442,8 +562,14 @@
442 562 for (const added of document.querySelectorAll(
443 563 '[data-extendify-temp-addition]',
444 564 ))
445 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 + }
446 572 for (const style of document.querySelectorAll(`style[${PREVIEW_CSS_ATTR}]`))
447 573 style.remove();
448 574 detached.current = [];
449 575 }, []);
@@ -448,10 +574,12 @@
448 574 detached.current = [];
449 575 }, []);
450 576
451 577 const confirmed = useRef(false);
578 + const unmounted = useRef(false);
452 579 useEffect(() => {
453 580 return () => {
581 + unmounted.current = true;
454 582 if (!confirmed.current) undoBlockChange();
455 583 };
456 584 }, [undoBlockChange]);
457 585
@@ -499,14 +627,16 @@
499 627 // An add preview has no original to restore — only count it.
500 628 if (original !== true) originals.push(original);
501 629 }
502 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();
503 633 // Nothing rendered means none of the target blocks are on the page.
504 634 if (!rendered) return onCancel();
505 635 setLoading(false);
506 636 };
507 637 run();
508 - }, [block, inputs, onCancel, operations]);
638 + }, [block, inputs, onCancel, operations, undoBlockChange]);
509 639
510 640 if (loading)
511 641 return (
512 642 <Wrapper>
@@ -536,12 +666,17 @@
536 666 ? __(
537 667 'The agent has placed the block in its new container in the browser. Please review and confirm.',
538 668 'extendify-local',
539 669 )
540 - : __(
541 - 'The agent has made the changes in the browser. Please review and confirm.',
542 - 'extendify-local',
543 - );
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 + );
544 679
545 680 return (
546 681 <Wrapper>
547 682 <Content>
@@ -580,9 +715,9 @@
580 715 );
581 716 };
582 717
583 718 const Wrapper = ({ children }) => (
584 - <div className="mb-4 ms-12 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50">
719 + <div className="mb-4 ms-2 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50">
585 720 {children}
586 721 </div>
587 722 );
588 723