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

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

543 lines 17.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { fetchBlockCodeById } from '@agent/lib/block-code';
2 import { applyBlockPatch } from '@agent/lib/block-patch';
3 import { processCustomCss } from '@agent/lib/custom-css';
4 import { resolveDeleteTarget } from '@agent/lib/delete-target';
5 import { buildNewBlock } from '@agent/lib/insertable-blocks';
6 import { useQuickEditStore } from '@quick-edit/state/store';
7 import { patchVariantClasses } from '@shared/lib/variant-classes';
8 import apiFetch from '@wordpress/api-fetch';
9 import { parse } from '@wordpress/blocks';
10 import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
11 import { __ } from '@wordpress/i18n';
12
13 const dynamicClasses = ['is-style-ext-preset', 'is-style-outline'];
14 const wpBlockAttributeClasses =
15 /^has-([\w-]+-)?(background-color|color|font-size|gradient-background)$|^has-background$|^has-text-color$/;
16 // Carrying stale align/layout classes renders the preview with the old layout
17 const layoutEngineClasses =
18 /^align(full|wide|left|right|center)$|^is-layout-|^wp-container-|-is-layout-|^is-content-justification-|^is-(vertical|horizontal|nowrap|wrap)$|^has-global-padding$/;
19 const themeAnimationClasses = /^ext-animated?(-|$)/;
20
21 // Re-set ext-animated to prevent animating while patching
22 const pinThemeAnimations = (el) => {
23 for (const node of [el, ...el.querySelectorAll('.ext-animate')]) {
24 if (node.classList?.contains('ext-animate'))
25 node.dataset.extAnimated = 'true';
26 }
27 };
28 const PREVIEW_CSS_ATTR = 'data-extendify-preview-css';
29
30 const cssOf = (blockCode) =>
31 parse(blockCode)[0]?.attributes?.style?.css || null;
32
33 // The wp-container-* layout rules enqueue page-side on a full render only, so
34 // the fragment ships them and the preview injects them, tagged for teardown.
35 const injectPreviewStylesheet = (blockId, cssText) => {
36 if (!cssText) return;
37 const style = document.createElement('style');
38 style.setAttribute(PREVIEW_CSS_ATTR, blockId);
39 style.textContent = cssText;
40 document.head.appendChild(style);
41 };
42
43 // The block fragment ships without style.css's server rule, so inject it here,
44 // tagged for teardown. No rule means WP discards this CSS too — show nothing.
45 const injectPreviewCss = (el, blockId, css) => {
46 const cls = `ext-preview-css-${blockId}`;
47 const rule = processCustomCss(css, `.${cls}`);
48 if (!rule) return;
49 el.classList.add(cls);
50 const style = document.createElement('style');
51 style.setAttribute(PREVIEW_CSS_ATTR, blockId);
52 style.textContent = rule;
53 document.head.appendChild(style);
54 };
55
56 // Swap the rendered preview in for the live element. Returns the detached
57 // original (restored on cancel), or null when the target isn't on the page.
58 const previewBlock = async (blockId, newContent, css) => {
59 const { content, styles } = await apiFetch({
60 path: '/extendify/v1/agent/get-block-html',
61 method: 'POST',
62 data: { blockCode: newContent },
63 });
64 const el = document.querySelector(
65 `[data-extendify-agent-block-id="${blockId}"]`,
66 );
67 if (!el) return null;
68 injectPreviewStylesheet(blockId, styles);
69
70 const patched = patchVariantClasses(
71 content,
72 el.cloneNode(true),
73 dynamicClasses,
74 );
75 const template = document.createElement('template');
76 template.innerHTML = patched || '<div style="display:none"></div>';
77 const newEl = template.content.firstElementChild;
78 if (!newEl) return null;
79
80 // Later ops anchor by id — the replacement and its children keep theirs;
81 // an attribute edit preserves child structure, so ids map by position.
82 newEl.setAttribute('data-extendify-agent-block-id', blockId);
83 for (const tagged of el.querySelectorAll('[data-extendify-agent-block-id]')) {
84 const path = [];
85 for (let node = tagged; node !== el; node = node.parentElement) {
86 if (!node.parentElement) break;
87 path.unshift([...node.parentElement.children].indexOf(node));
88 }
89 const match = path.reduce((node, i) => node?.children?.[i], newEl);
90 match?.setAttribute(
91 'data-extendify-agent-block-id',
92 tagged.getAttribute('data-extendify-agent-block-id'),
93 );
94 }
95 const newElClasses = new Set(newEl.classList);
96 el.classList.forEach((className) => {
97 if (newElClasses.has(className)) return;
98 if (wpBlockAttributeClasses.test(className)) return;
99 if (layoutEngineClasses.test(className)) return;
100 if (themeAnimationClasses.test(className)) return;
101 newEl.classList.add(className);
102 });
103 // The custom-CSS hash class points at a stale/absent server rule — drop it;
104 // injectPreviewCss applies the current css.
105 for (const className of [...newEl.classList]) {
106 if (
107 className === 'has-custom-css' ||
108 className.startsWith('wp-custom-css-')
109 )
110 newEl.classList.remove(className);
111 }
112 if (css) injectPreviewCss(newEl, blockId, css);
113 newEl.setAttribute('data-extendify-temp-replacement', blockId);
114 // ext-animate--on sets opacity:0 and won't re-run on a replaced node, so the
115 // preview would stay invisible — strip it.
116 for (const node of [newEl, ...newEl.querySelectorAll('.ext-animate--on')]) {
117 node.classList.remove('ext-animate--on');
118 }
119 el.parentNode.insertBefore(newEl, el.nextSibling);
120 el.parentNode.removeChild(el);
121 return el;
122 };
123
124 // Relocate the live node; a hidden marker holds its old slot so undo can put it back.
125 const previewMove = ({ blockId, targetId, position }) => {
126 const el = document.querySelector(
127 `[data-extendify-agent-block-id="${blockId}"]`,
128 );
129 const target = document.querySelector(
130 `[data-extendify-agent-block-id="${targetId}"]`,
131 );
132 if (!el || !target) return null;
133 const marker = document.createElement('div');
134 marker.style.display = 'none';
135 marker.setAttribute('data-extendify-temp-replacement', blockId);
136 el.parentNode.insertBefore(marker, el);
137 pinThemeAnimations(el);
138 target.parentNode.insertBefore(
139 el,
140 position === 'after' ? target.nextSibling : target,
141 );
142 return el;
143 };
144
145 const renderAddedEl = async (block, index) => {
146 const { content, styles } = await apiFetch({
147 path: '/extendify/v1/agent/get-block-html',
148 method: 'POST',
149 data: { blockCode: block },
150 });
151 if (!content) return null;
152 injectPreviewStylesheet(`add-${index}`, styles);
153 const template = document.createElement('template');
154 template.innerHTML = content;
155 const newEl = template.content.firstElementChild;
156 if (!newEl) return null;
157 newEl.setAttribute('data-extendify-temp-addition', '');
158 const css = cssOf(block);
159 if (css) injectPreviewCss(newEl, `add-${index}`, css);
160 for (const node of [newEl, ...newEl.querySelectorAll('.ext-animate--on')]) {
161 node.classList.remove('ext-animate--on');
162 }
163 return newEl;
164 };
165
166 // Render the new block and slot it next to its anchor. Nothing detaches —
167 // returns true so the caller counts it rendered; undo just removes the node.
168 const previewAdd = async ({ anchorId, position, block }, index) => {
169 const anchor = document.querySelector(
170 `[data-extendify-agent-block-id="${anchorId}"]`,
171 );
172 if (!anchor) return null;
173 const newEl = await renderAddedEl(block, index);
174 if (!newEl) return null;
175 anchor.parentNode.insertBefore(
176 newEl,
177 position === 'after' ? anchor.nextSibling : anchor,
178 );
179 return true;
180 };
181
182 // Mirror the server's column routing off the DOM (spliceColumn owns the why).
183 const previewColumnAdd = async (
184 { anchorId, position, block },
185 index,
186 wrappers,
187 ) => {
188 const anchor = document.querySelector(
189 `[data-extendify-agent-block-id="${anchorId}"]`,
190 );
191 if (!anchor) return null;
192 if (anchor.classList.contains('wp-block-column')) {
193 return previewAdd({ anchorId, position, block }, index);
194 }
195 const shared = wrappers.get(`${anchorId}:${position}`);
196 if (shared) {
197 const newEl = await renderAddedEl(block, index);
198 if (!newEl) return null;
199 shared.appendChild(newEl);
200 return true;
201 }
202 const newEl = await renderAddedEl(
203 `<!-- wp:columns --><div class="wp-block-columns">${block}</div><!-- /wp:columns -->`,
204 index,
205 );
206 if (!newEl) return null;
207 anchor.parentNode.insertBefore(
208 newEl,
209 position === 'after' ? anchor.nextSibling : anchor,
210 );
211 wrappers.set(`${anchorId}:${position}`, newEl);
212 return true;
213 };
214
215 // Keyed by the model-facing container word — the code supplies the
216 // core/columns parent a bare column needs, mirroring the server templates.
217 const WRAP_SHELLS = {
218 'core/column':
219 '<!-- wp:columns --><div class="wp-block-columns"><!-- wp:column --><div class="wp-block-column"></div><!-- /wp:column --></div><!-- /wp:columns -->',
220 'core/group':
221 '<!-- wp:group {"layout":{"type":"constrained"}} --><div class="wp-block-group"></div><!-- /wp:group -->',
222 };
223
224 // The relocated node keeps its block id, so a later add in the batch can
225 // still anchor to it; a hidden marker holds its old slot for undo.
226 const previewWrap = async ({ blockId, container }, wrappers) => {
227 const el = document.querySelector(
228 `[data-extendify-agent-block-id="${blockId}"]`,
229 );
230 const shellCode = WRAP_SHELLS[container];
231 if (!el || !shellCode) return null;
232 // Two column wraps in one batch share one section, mirroring the save.
233 const sharedShell =
234 container === 'core/column' ? wrappers.get('wrap-shell') : null;
235 if (sharedShell && !el.contains(sharedShell)) {
236 const marker = document.createElement('div');
237 marker.style.display = 'none';
238 marker.setAttribute('data-extendify-temp-replacement', blockId);
239 el.parentNode.insertBefore(marker, el);
240 const column = document.createElement('div');
241 column.className = 'wp-block-column';
242 pinThemeAnimations(el);
243 column.appendChild(el);
244 sharedShell.appendChild(column);
245 wrappers.set(`${blockId}:after`, sharedShell);
246 wrappers.set(`${blockId}:before`, sharedShell);
247 return el;
248 }
249 const { content } = await apiFetch({
250 path: '/extendify/v1/agent/get-block-html',
251 method: 'POST',
252 data: { blockCode: shellCode },
253 });
254 const template = document.createElement('template');
255 template.innerHTML = content ?? '';
256 const shell = template.content.firstElementChild;
257 if (!shell) return null;
258 shell.setAttribute('data-extendify-temp-addition', '');
259 if (container === 'core/column') {
260 // A later column add anchored to the wrapped block joins this shell.
261 wrappers.set(`${blockId}:after`, shell);
262 wrappers.set(`${blockId}:before`, shell);
263 wrappers.set('wrap-shell', shell);
264 }
265 const marker = document.createElement('div');
266 marker.style.display = 'none';
267 marker.setAttribute('data-extendify-temp-replacement', blockId);
268 el.parentNode.insertBefore(marker, el);
269 el.parentNode.insertBefore(shell, marker);
270 pinThemeAnimations(el);
271 (shell.querySelector('.wp-block-column') ?? shell).appendChild(el);
272 return el;
273 };
274
275 // Remove the target, leaving a hidden marker so cancel restores it like a swapped preview.
276 const previewDelete = (blockId) => {
277 const el = document.querySelector(
278 `[data-extendify-agent-block-id="${blockId}"]`,
279 );
280 if (!el) return null;
281 const marker = document.createElement('div');
282 marker.style.display = 'none';
283 marker.setAttribute('data-extendify-temp-replacement', blockId);
284 el.parentNode.insertBefore(marker, el.nextSibling);
285 el.parentNode.removeChild(el);
286 return el;
287 };
288
289 // Each target pairs the operation that saves with the preview that shows it.
290 // Delete and move ids resolve off the pristine DOM before the preview
291 // detaches anything, so preview + save agree on wrapper targets.
292 const buildOperationTarget = async (
293 operation,
294 block,
295 postId,
296 index,
297 wrappers,
298 ) => {
299 if (operation?.op === 'add') {
300 // Same builder the save-time tool uses, so preview and save agree.
301 const markup = buildNewBlock(
302 operation.blockType,
303 operation.patch,
304 operation.clear ?? [],
305 window.extAgentData?.context?.presetSlugs ?? {},
306 );
307 return {
308 operation,
309 preview: () => {
310 if (!markup) return null;
311 const withMarkup = { ...operation, block: markup };
312 return operation.blockType === 'core/column'
313 ? previewColumnAdd(withMarkup, index, wrappers)
314 : previewAdd(withMarkup, index);
315 },
316 };
317 }
318 if (operation?.op === 'wrap') {
319 // Wrapping just a lone child nests the new container inside its old wrapper.
320 const resolved = {
321 ...operation,
322 blockId: resolveDeleteTarget(operation.blockId),
323 };
324 return {
325 operation: resolved,
326 preview: () => previewWrap(resolved, wrappers),
327 };
328 }
329 if (operation?.op === 'move') {
330 const resolved = {
331 ...operation,
332 blockId: resolveDeleteTarget(operation.blockId),
333 };
334 return { operation: resolved, preview: () => previewMove(resolved) };
335 }
336 if (operation?.op === 'delete') {
337 const resolved = {
338 ...operation,
339 blockId: resolveDeleteTarget(operation.blockId),
340 };
341 return {
342 operation: resolved,
343 preview: () => previewDelete(resolved.blockId),
344 };
345 }
346 // Image swaps live in ReplaceImageConfirm; a stray one here saves as no-change.
347 if (operation?.op === 'replace-image')
348 return { operation, preview: () => null };
349 const { blockId, patch, clear } = operation ?? {};
350 const newContent = applyBlockPatch(
351 await fetchBlockCodeById(blockId, block?.source, postId),
352 patch,
353 clear ?? [],
354 window.extAgentData?.context?.presetSlugs ?? {},
355 );
356 return {
357 operation,
358 preview: () =>
359 newContent ? previewBlock(blockId, newContent, cssOf(newContent)) : null,
360 };
361 };
362
363 // block-general workflows still send a whole-block newContent replace.
364 const buildLegacyTarget = (inputs, block) => ({
365 operation: null,
366 preview: () =>
367 inputs.newContent
368 ? previewBlock(block?.id, inputs.newContent, cssOf(inputs.newContent))
369 : null,
370 });
371
372 export const UpdateBlockConfirm = ({
373 inputs,
374 onConfirm,
375 onCancel,
376 onRetry,
377 }) => {
378 const block = useQuickEditStore((s) => s.agentBlock);
379 const [loading, setLoading] = useState(true);
380 const detached = useRef([]);
381 // What actually saves — delete rewrites this to the DOM-resolved wrapper ids.
382 const saveData = useRef(inputs);
383
384 const operations = Array.isArray(inputs.operations)
385 ? inputs.operations
386 : null;
387
388 const undoBlockChange = useCallback(() => {
389 for (const original of detached.current) {
390 const replacement = document.querySelector(
391 `[data-extendify-temp-replacement="${original.getAttribute('data-extendify-agent-block-id')}"]`,
392 );
393 pinThemeAnimations(original);
394 replacement?.parentNode?.insertBefore(original, replacement);
395 replacement?.remove();
396 }
397 for (const added of document.querySelectorAll(
398 '[data-extendify-temp-addition]',
399 ))
400 added.remove();
401 for (const style of document.querySelectorAll(`style[${PREVIEW_CSS_ATTR}]`))
402 style.remove();
403 detached.current = [];
404 }, []);
405
406 const confirmed = useRef(false);
407 useEffect(() => {
408 return () => {
409 if (!confirmed.current) undoBlockChange();
410 };
411 }, [undoBlockChange]);
412
413 const handleConfirm = async () => {
414 confirmed.current = true;
415 await onConfirm({ data: saveData.current, shouldRefreshPage: true });
416 };
417
418 const handleRetry = useCallback(() => {
419 undoBlockChange();
420 onRetry();
421 }, [undoBlockChange, onRetry]);
422
423 // Re-renders (the staged block changes identity on page clicks) must not
424 // inject the preview again and clobber the undo list.
425 const previewed = useRef(false);
426 useEffect(() => {
427 if (previewed.current) return;
428 previewed.current = true;
429 const run = async () => {
430 const postId = window.extAgentData?.context?.postId;
431 const operations = Array.isArray(inputs.operations)
432 ? inputs.operations
433 : null;
434 const wrappers = new Map();
435 const targets = operations
436 ? await Promise.all(
437 operations.map((operation, index) =>
438 buildOperationTarget(operation, block, postId, index, wrappers),
439 ),
440 )
441 : [buildLegacyTarget(inputs, block)];
442 if (operations)
443 saveData.current = {
444 ...inputs,
445 operations: targets.map(({ operation }) => operation),
446 };
447
448 const originals = [];
449 let rendered = 0;
450 for (const target of targets) {
451 const original = await target.preview();
452 if (!original) continue;
453 rendered++;
454 // An add preview has no original to restore — only count it.
455 if (original !== true) originals.push(original);
456 }
457 detached.current = originals;
458 // Nothing rendered means none of the target blocks are on the page.
459 if (!rendered) return onCancel();
460 setLoading(false);
461 };
462 run();
463 }, [block, inputs, onCancel, operations]);
464
465 if (loading)
466 return (
467 <Wrapper>
468 <Content>{__('Loading...', 'extendify-local')}</Content>
469 </Wrapper>
470 );
471
472 const onlyOp = (op) =>
473 Array.isArray(inputs.operations) &&
474 inputs.operations.every((operation) => operation?.op === op);
475 const message = onlyOp('delete')
476 ? __(
477 'The agent will remove the selected block. Please review and confirm.',
478 'extendify-local',
479 )
480 : onlyOp('move')
481 ? __(
482 'The agent has rearranged the blocks in the browser. Please review and confirm.',
483 'extendify-local',
484 )
485 : onlyOp('add')
486 ? __(
487 'The agent has added the new block in the browser. Please review and confirm.',
488 'extendify-local',
489 )
490 : onlyOp('wrap')
491 ? __(
492 'The agent has placed the block in its new container in the browser. Please review and confirm.',
493 'extendify-local',
494 )
495 : __(
496 'The agent has made the changes in the browser. Please review and confirm.',
497 'extendify-local',
498 );
499
500 return (
501 <Wrapper>
502 <Content>
503 <p className="m-0 p-0 text-sm text-gray-900">{message}</p>
504 </Content>
505 <div className="flex flex-wrap justify-start gap-2 p-3">
506 <button
507 type="button"
508 className="flex-1 rounded-sm border border-gray-500 bg-white p-2 text-sm text-gray-900"
509 onClick={onCancel}
510 >
511 {__('Cancel', 'extendify-local')}
512 </button>
513 <button
514 type="button"
515 className="flex-1 rounded-sm border border-gray-500 bg-white p-2 text-sm text-gray-900"
516 onClick={handleRetry}
517 >
518 {__('Try Again', 'extendify-local')}
519 </button>
520 <button
521 type="button"
522 className="flex-1 rounded-sm border border-design-main bg-design-main p-2 text-sm text-white"
523 onClick={handleConfirm}
524 >
525 {__('Save', 'extendify-local')}
526 </button>
527 </div>
528 </Wrapper>
529 );
530 };
531
532 const Wrapper = ({ children }) => (
533 <div className="mb-4 ms-12 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50">
534 {children}
535 </div>
536 );
537
538 const Content = ({ children }) => (
539 <div className="rounded-lg border-b border-gray-300 bg-white">
540 <div className="p-3">{children}</div>
541 </div>
542 );
543