← All changes
|
src/Agent/workflows/block-selector/components/UpdateBlockConfirm.jsx
+504
-61
3.1.2
→
3.2.1
View file →
| @@ -1,12 +1,420 @@ | ||
| 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'; | |
| 1 | 16 | import { useQuickEditStore } from '@quick-edit/state/store'; |
| 2 | 17 | import { patchVariantClasses } from '@shared/lib/variant-classes'; |
| 3 | 18 | import apiFetch from '@wordpress/api-fetch'; |
| 19 | +import { parse } from '@wordpress/blocks'; | |
| 4 | 20 | import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; |
| 5 | 21 | import { __ } from '@wordpress/i18n'; |
| 6 | 22 | |
| 7 | 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?(-|$)/; | |
| 8 | 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 | + | |
| 9 | 417 | export const UpdateBlockConfirm = ({ |
| 10 | 418 | inputs, |
| 11 | 419 | onConfirm, |
| 12 | 420 | onCancel, |
| @@ -13,17 +421,32 @@ | ||
| 13 | 421 | onRetry, |
| 14 | 422 | }) => { |
| 15 | 423 | const block = useQuickEditStore((s) => s.agentBlock); |
| 16 | 424 | const [loading, setLoading] = useState(true); |
| 17 | - const detachedEl = useRef(null); | |
| 425 | + const detached = useRef([]); | |
| 426 | + // What actually saves — delete rewrites this to the DOM-resolved wrapper ids. | |
| 427 | + const saveData = useRef(inputs); | |
| 18 | 428 | |
| 429 | + const operations = Array.isArray(inputs.operations) | |
| 430 | + ? inputs.operations | |
| 431 | + : null; | |
| 432 | + | |
| 19 | 433 | 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; | |
| 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(); | |
| 24 | 441 | } |
| 25 | - if (el) el.remove(); | |
| 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 = []; | |
| 26 | 449 | }, []); |
| 27 | 450 | |
| 28 | 451 | const confirmed = useRef(false); |
| 29 | 452 | useEffect(() => { |
| @@ -29,13 +452,13 @@ | ||
| 29 | 452 | useEffect(() => { |
| 30 | 453 | return () => { |
| 31 | 454 | if (!confirmed.current) undoBlockChange(); |
| 32 | 455 | }; |
| 33 | - }, []); | |
| 456 | + }, [undoBlockChange]); | |
| 34 | 457 | |
| 35 | 458 | const handleConfirm = async () => { |
| 36 | 459 | confirmed.current = true; |
| 37 | - await onConfirm({ data: inputs, shouldRefreshPage: true }); | |
| 460 | + await onConfirm({ data: saveData.current, shouldRefreshPage: true }); | |
| 38 | 461 | }; |
| 39 | 462 | |
| 40 | 463 | const handleRetry = useCallback(() => { |
| 41 | 464 | undoBlockChange(); |
| @@ -41,58 +464,49 @@ | ||
| 41 | 464 | undoBlockChange(); |
| 42 | 465 | onRetry(); |
| 43 | 466 | }, [undoBlockChange, onRetry]); |
| 44 | 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); | |
| 45 | 471 | 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; | |
| 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 | + }; | |
| 61 | 492 | |
| 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'); | |
| 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); | |
| 89 | 501 | } |
| 90 | - el.parentNode.insertBefore(newEl, el.nextSibling); | |
| 91 | - el.parentNode?.removeChild(el); | |
| 502 | + detached.current = originals; | |
| 503 | + // Nothing rendered means none of the target blocks are on the page. | |
| 504 | + if (!rendered) return onCancel(); | |
| 92 | 505 | setLoading(false); |
| 93 | - }); | |
| 94 | - }, [block, inputs, onCancel]); | |
| 506 | + }; | |
| 507 | + run(); | |
| 508 | + }, [block, inputs, onCancel, operations]); | |
| 95 | 509 | |
| 96 | 510 | if (loading) |
| 97 | 511 | return ( |
| 98 | 512 | <Wrapper> |
| @@ -99,17 +513,46 @@ | ||
| 99 | 513 | <Content>{__('Loading...', 'extendify-local')}</Content> |
| 100 | 514 | </Wrapper> |
| 101 | 515 | ); |
| 102 | 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 | + | |
| 103 | 545 | return ( |
| 104 | 546 | <Wrapper> |
| 105 | 547 | <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> | |
| 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 | + /> | |
| 112 | 555 | </Content> |
| 113 | 556 | <div className="flex flex-wrap justify-start gap-2 p-3"> |
| 114 | 557 | <button |
| 115 | 558 | type="button" |
| @@ -137,9 +580,9 @@ | ||
| 137 | 580 | ); |
| 138 | 581 | }; |
| 139 | 582 | |
| 140 | 583 | 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"> | |
| 584 | + <div className="mb-4 ms-2 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50"> | |
| 142 | 585 | {children} |
| 143 | 586 | </div> |
| 144 | 587 | ); |
| 145 | 588 | |