| 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 |
|