PluginProbe
Extendify / trunk
Extendify vtrunk
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
← All changes | src/Agent/workflows/block-selector/components/UpdateBlockConfirm.jsx +97 -46 3.1.4 → trunk View file →
@@ -1,9 +1,19 @@
1 +import { SharedBlockNotice } from '@agent/components/SharedBlockNotice';
1 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';
2 11 import { applyBlockPatch } from '@agent/lib/block-patch';
3 12 import { processCustomCss } from '@agent/lib/custom-css';
4 13 import { resolveDeleteTarget } from '@agent/lib/delete-target';
5 14 import { buildNewBlock } from '@agent/lib/insertable-blocks';
15 +import { SETTING_TEXT_BLOCKS } from '@agent/lib/setting-text-blocks';
6 16 import { useQuickEditStore } from '@quick-edit/state/store';
7 17 import { patchVariantClasses } from '@shared/lib/variant-classes';
8 18 import apiFetch from '@wordpress/api-fetch';
9 19 import { parse } from '@wordpress/blocks';
@@ -25,9 +35,17 @@
25 35 node.dataset.extAnimated = 'true';
26 36 }
27 37 };
28 38 const PREVIEW_CSS_ATTR = 'data-extendify-preview-css';
39 +const PART_SLUG_ATTR = 'data-extendify-part-slug';
29 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 +
30 48 const cssOf = (blockCode) =>
31 49 parse(blockCode)[0]?.attributes?.style?.css || null;
32 50
33 51 // The wp-container-* layout rules enqueue page-side on a full render only, so
@@ -54,17 +72,15 @@
54 72 };
55 73
56 74 // Swap the rendered preview in for the live element. Returns the detached
57 75 // original (restored on cancel), or null when the target isn't on the page.
58 -const previewBlock = async (blockId, newContent, css) => {
76 +const previewBlock = async (blockId, newContent, css, scope) => {
59 77 const { content, styles } = await apiFetch({
60 78 path: '/extendify/v1/agent/get-block-html',
61 79 method: 'POST',
62 80 data: { blockCode: newContent },
63 81 });
64 - const el = document.querySelector(
65 - `[data-extendify-agent-block-id="${blockId}"]`,
66 - );
82 + const el = findBlockEl(blockId, document, scope);
67 83 if (!el) return null;
68 84 injectPreviewStylesheet(blockId, styles);
69 85
70 86 const patched = patchVariantClasses(
@@ -78,10 +94,11 @@
78 94 if (!newEl) return null;
79 95
80 96 // Later ops anchor by id — the replacement and its children keep theirs;
81 97 // 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]')) {
98 + newEl.setAttribute(idAttrOf(el), blockId);
99 + carryPartSlug(el, newEl);
100 + for (const tagged of el.querySelectorAll(BLOCK_ID_SEL)) {
84 101 const path = [];
85 102 for (let node = tagged; node !== el; node = node.parentElement) {
86 103 if (!node.parentElement) break;
87 104 path.unshift([...node.parentElement.children].indexOf(node));
@@ -86,12 +103,10 @@
86 103 if (!node.parentElement) break;
87 104 path.unshift([...node.parentElement.children].indexOf(node));
88 105 }
89 106 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 - );
107 + match?.setAttribute(idAttrOf(tagged), blockIdOf(tagged));
108 + carryPartSlug(tagged, match);
94 109 }
95 110 const newElClasses = new Set(newEl.classList);
96 111 el.classList.forEach((className) => {
97 112 if (newElClasses.has(className)) return;
@@ -121,15 +136,11 @@
121 136 return el;
122 137 };
123 138
124 139 // 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 - );
140 +const previewMove = ({ blockId, targetId, position }, scope) => {
141 + const el = findBlockEl(blockId, document, scope);
142 + const target = findBlockEl(targetId, document, scope);
132 143 if (!el || !target) return null;
133 144 const marker = document.createElement('div');
134 145 marker.style.display = 'none';
135 146 marker.setAttribute('data-extendify-temp-replacement', blockId);
@@ -164,12 +175,10 @@
164 175 };
165 176
166 177 // Render the new block and slot it next to its anchor. Nothing detaches —
167 178 // 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 - );
179 +const previewAdd = async ({ anchorId, position, block }, index, scope) => {
180 + const anchor = findBlockEl(anchorId, document, scope);
172 181 if (!anchor) return null;
173 182 const newEl = await renderAddedEl(block, index);
174 183 if (!newEl) return null;
175 184 anchor.parentNode.insertBefore(
@@ -183,15 +192,14 @@
183 192 const previewColumnAdd = async (
184 193 { anchorId, position, block },
185 194 index,
186 195 wrappers,
196 + scope,
187 197 ) => {
188 - const anchor = document.querySelector(
189 - `[data-extendify-agent-block-id="${anchorId}"]`,
190 - );
198 + const anchor = findBlockEl(anchorId, document, scope);
191 199 if (!anchor) return null;
192 200 if (anchor.classList.contains('wp-block-column')) {
193 - return previewAdd({ anchorId, position, block }, index);
201 + return previewAdd({ anchorId, position, block }, index, scope);
194 202 }
195 203 const shared = wrappers.get(`${anchorId}:${position}`);
196 204 if (shared) {
197 205 const newEl = await renderAddedEl(block, index);
@@ -222,12 +230,10 @@
222 230 };
223 231
224 232 // The relocated node keeps its block id, so a later add in the batch can
225 233 // 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 - );
234 +const previewWrap = async ({ blockId, container }, wrappers, scope) => {
235 + const el = findBlockEl(blockId, document, scope);
230 236 const shellCode = WRAP_SHELLS[container];
231 237 if (!el || !shellCode) return null;
232 238 // Two column wraps in one batch share one section, mirroring the save.
233 239 const sharedShell =
@@ -271,13 +277,24 @@
271 277 (shell.querySelector('.wp-block-column') ?? shell).appendChild(el);
272 278 return el;
273 279 };
274 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 +
275 294 // 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 - );
295 +const previewDelete = (blockId, scope) => {
296 + const el = findBlockEl(blockId, document, scope);
280 297 if (!el) return null;
281 298 const marker = document.createElement('div');
282 299 marker.style.display = 'none';
283 300 marker.setAttribute('data-extendify-temp-replacement', blockId);
@@ -285,18 +302,33 @@
285 302 el.parentNode.removeChild(el);
286 303 return el;
287 304 };
288 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 +
289 320 // Each target pairs the operation that saves with the preview that shows it.
290 321 // Delete and move ids resolve off the pristine DOM before the preview
291 322 // detaches anything, so preview + save agree on wrapper targets.
292 323 const buildOperationTarget = async (
293 - operation,
324 + rawOperation,
294 325 block,
295 326 postId,
296 327 index,
297 328 wrappers,
298 329 ) => {
330 + const { operation, scope } = unscope(rawOperation, scopeOf(block));
299 331 if (operation?.op === 'add') {
300 332 // Same builder the save-time tool uses, so preview and save agree.
301 333 const markup = buildNewBlock(
302 334 operation.blockType,
@@ -309,10 +341,10 @@
309 341 preview: () => {
310 342 if (!markup) return null;
311 343 const withMarkup = { ...operation, block: markup };
312 344 return operation.blockType === 'core/column'
313 - ? previewColumnAdd(withMarkup, index, wrappers)
314 - : previewAdd(withMarkup, index);
345 + ? previewColumnAdd(withMarkup, index, wrappers, scope)
346 + : previewAdd(withMarkup, index, scope);
315 347 },
316 348 };
317 349 }
318 350 if (operation?.op === 'wrap') {
@@ -318,30 +350,30 @@
318 350 if (operation?.op === 'wrap') {
319 351 // Wrapping just a lone child nests the new container inside its old wrapper.
320 352 const resolved = {
321 353 ...operation,
322 - blockId: resolveDeleteTarget(operation.blockId),
354 + blockId: resolveDeleteTarget(operation.blockId, scope),
323 355 };
324 356 return {
325 357 operation: resolved,
326 - preview: () => previewWrap(resolved, wrappers),
358 + preview: () => previewWrap(resolved, wrappers, scope),
327 359 };
328 360 }
329 361 if (operation?.op === 'move') {
330 362 const resolved = {
331 363 ...operation,
332 - blockId: resolveDeleteTarget(operation.blockId),
364 + blockId: resolveDeleteTarget(operation.blockId, scope),
333 365 };
334 - return { operation: resolved, preview: () => previewMove(resolved) };
366 + return { operation: resolved, preview: () => previewMove(resolved, scope) };
335 367 }
336 368 if (operation?.op === 'delete') {
337 369 const resolved = {
338 370 ...operation,
339 - blockId: resolveDeleteTarget(operation.blockId),
371 + blockId: resolveDeleteTarget(operation.blockId, scope),
340 372 };
341 373 return {
342 374 operation: resolved,
343 - preview: () => previewDelete(resolved.blockId),
375 + preview: () => previewDelete(resolved.blockId, scope),
344 376 };
345 377 }
346 378 // Image swaps live in ReplaceImageConfirm; a stray one here saves as no-change.
347 379 if (operation?.op === 'replace-image')
@@ -346,8 +378,14 @@
346 378 // Image swaps live in ReplaceImageConfirm; a stray one here saves as no-change.
347 379 if (operation?.op === 'replace-image')
348 380 return { operation, preview: () => null };
349 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 + }
350 388 const newContent = applyBlockPatch(
351 389 await fetchBlockCodeById(blockId, block?.source, postId),
352 390 patch,
353 391 clear ?? [],
@@ -355,9 +393,11 @@
355 393 );
356 394 return {
357 395 operation,
358 396 preview: () =>
359 - newContent ? previewBlock(blockId, newContent, cssOf(newContent)) : null,
397 + newContent
398 + ? previewBlock(blockId, newContent, cssOf(newContent), scope)
399 + : null,
360 400 };
361 401 };
362 402
363 403 // block-general workflows still send a whole-block newContent replace.
@@ -364,9 +404,14 @@
364 404 const buildLegacyTarget = (inputs, block) => ({
365 405 operation: null,
366 406 preview: () =>
367 407 inputs.newContent
368 - ? previewBlock(block?.id, inputs.newContent, cssOf(inputs.newContent))
408 + ? previewBlock(
409 + block?.id,
410 + inputs.newContent,
411 + cssOf(inputs.newContent),
412 + scopeOf(block),
413 + )
369 414 : null,
370 415 });
371 416
372 417 export const UpdateBlockConfirm = ({
@@ -387,9 +432,9 @@
387 432
388 433 const undoBlockChange = useCallback(() => {
389 434 for (const original of detached.current) {
390 435 const replacement = document.querySelector(
391 - `[data-extendify-temp-replacement="${original.getAttribute('data-extendify-agent-block-id')}"]`,
436 + `[data-extendify-temp-replacement="${CSS.escape(blockIdOf(original))}"]`,
392 437 );
393 438 pinThemeAnimations(original);
394 439 replacement?.parentNode?.insertBefore(original, replacement);
395 440 replacement?.remove();
@@ -500,8 +545,14 @@
500 545 return (
501 546 <Wrapper>
502 547 <Content>
503 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 + />
504 555 </Content>
505 556 <div className="flex flex-wrap justify-start gap-2 p-3">
506 557 <button
507 558 type="button"
@@ -529,9 +580,9 @@
529 580 );
530 581 };
531 582
532 583 const Wrapper = ({ children }) => (
533 - <div className="mb-4 ms-12 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50">
584 + <div className="mb-4 ms-2 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50">
534 585 {children}
535 586 </div>
536 587 );
537 588