PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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.2.1, at src/Agent/workflows/block-selector/components/UpdateBlockConfirm.jsx

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