| @@ -5,9 +5,9 @@ | ||
| 5 | 5 | import { __ } from '@wordpress/i18n'; |
| 6 | 6 | import { useEditModeStore } from '../state/edit-mode'; |
| 7 | 7 | import { useQuickEditStore } from '../state/store'; |
| 8 | 8 | import { whenAnimationsSettle } from './after-animations'; |
| 9 | -import { isAgentEligibleForTarget } from './agent-gate'; | |
| 9 | +import { escapesDynamicBlock, isAgentEligibleForTarget } from './agent-gate'; | |
| 10 | 10 | import { |
| 11 | 11 | askAiAboutElement, |
| 12 | 12 | hasAgentBlockSelected, |
| 13 | 13 | isAgentAvailable, |
| @@ -106,9 +106,14 @@ | ||
| 106 | 106 | const PART_ATTR = 'data-extendify-part-block-id'; |
| 107 | 107 | const PRODUCT_ATTR = 'data-extendify-quick-edit-product-id'; |
| 108 | 108 | const WPFORM_FIELD_ATTR = 'data-extendify-quick-edit-wpform-field-id'; |
| 109 | 109 | const MEDIATEXT_MEDIA_ATTR = 'data-extendify-quick-edit-mediatext-media'; |
| 110 | +const PART_SLUG_ATTR = 'data-extendify-part-slug'; | |
| 110 | 111 | |
| 112 | +// A synced pattern's blocks live in the wp_block post the id names; Quick | |
| 113 | +// Edit's save only ever writes the container, so it can't reach them. | |
| 114 | +const isSyncedPatternId = (id) => /^block:\d+:\d+$/.test(String(id ?? '')); | |
| 115 | + | |
| 111 | 116 | // DOMHighlighter's class; a sync listener can't read a React prop. |
| 112 | 117 | export const isAgentWorking = () => |
| 113 | 118 | !!document.querySelector('.wp-site-blocks.extendify-agent-working'); |
| 114 | 119 | |
| @@ -114,13 +119,24 @@ | ||
| 114 | 119 | |
| 115 | 120 | // Resolve the live DOM node for the currently-staged agent block, so the |
| 116 | 121 | // click + hover gates can carve out "inside the staged block." Returns |
| 117 | 122 | // null when no block is staged or its node has detached from the tree. |
| 123 | +// Without the slug an id matches another part's block of the same number, and | |
| 124 | +// a click inside the staged block reads as an outside-click. | |
| 118 | 125 | const stagedBlockEl = () => { |
| 119 | 126 | const block = useQuickEditStore.getState().agentBlock; |
| 120 | 127 | if (!block?.id) return null; |
| 121 | 128 | const attr = block.target || POST_ATTR; |
| 122 | - return document.querySelector(`[${attr}="${CSS.escape(String(block.id))}"]`); | |
| 129 | + const slug = block.source?.partSlug || null; | |
| 130 | + const matches = [ | |
| 131 | + ...document.querySelectorAll(`[${attr}="${CSS.escape(String(block.id))}"]`), | |
| 132 | + ]; | |
| 133 | + const inScope = matches.filter( | |
| 134 | + (el) => | |
| 135 | + (el.closest(`[${PART_SLUG_ATTR}]`)?.getAttribute(PART_SLUG_ATTR) ?? | |
| 136 | + null) === slug, | |
| 137 | + ); | |
| 138 | + return inScope[0] ?? matches[0] ?? null; | |
| 123 | 139 | }; |
| 124 | 140 | |
| 125 | 141 | // Resolve the committed selection's live DOM node. buildTarget stashes |
| 126 | 142 | // the element reference on the descriptor; if the block has been swapped |
| @@ -223,9 +239,9 @@ | ||
| 223 | 239 | // a cover's inner-container) so the bar resolves to the nearest editable |
| 224 | 240 | // parent. Without this, hovering the middle of a hero cover that |
| 225 | 241 | // surfaces post-title returned blockType=null and — combined with the |
| 226 | 242 | // template-part source gating Ask AI off — produced no bar at all. |
| 227 | -const buildTarget = (el) => { | |
| 243 | +const buildTarget = (el, fromEl = el) => { | |
| 228 | 244 | let current = resolveTarget(el); |
| 229 | 245 | let safety = 5; |
| 230 | 246 | while ( |
| 231 | 247 | current && |
| @@ -236,8 +252,11 @@ | ||
| 236 | 252 | const next = resolveTarget(current.el.parentElement); |
| 237 | 253 | if (!next) return current; |
| 238 | 254 | current = next; |
| 239 | 255 | } |
| 256 | + if (current && escapesDynamicBlock(fromEl, current.el)) { | |
| 257 | + return { ...current, dynamicInterior: true }; | |
| 258 | + } | |
| 240 | 259 | return current; |
| 241 | 260 | }; |
| 242 | 261 | |
| 243 | 262 | // Picker blocks keep the bar visible because the dropdown anchors |
| @@ -261,12 +280,26 @@ | ||
| 261 | 280 | // with no pills isn't worth pinning. Exported so keyboard-entry gates Enter |
| 262 | 281 | // on the same signal the hover bar uses. |
| 263 | 282 | export const pillContextFor = (target) => { |
| 264 | 283 | const quickEditEnabled = !!window.extQuickEditData?.quickEditEnabled; |
| 284 | + // A synced pattern is addressed off whichever tagger stamped it, so both | |
| 285 | + // id spaces have to be checked or the pill returns on pages. | |
| 286 | + const compositeId = | |
| 287 | + target?.el?.getAttribute?.(PART_ATTR) ?? | |
| 288 | + target?.el?.getAttribute?.(POST_ATTR); | |
| 265 | 289 | const quickEditable = |
| 266 | - quickEditEnabled && hasQuickEditModalFor(target?.blockType); | |
| 290 | + quickEditEnabled && | |
| 291 | + hasQuickEditModalFor(target?.blockType) && | |
| 292 | + !isSyncedPatternId(compositeId); | |
| 267 | 293 | const sourceKind = target?.source?.kind ?? null; |
| 268 | - const agentSupportedSource = sourceKind === 'post' || sourceKind === null; | |
| 294 | + // A ref-nav item routes Quick Edit's save through wp_navigation, but the | |
| 295 | + // agent reaches it through the id the part tagger stamped. | |
| 296 | + const agentSupportedSource = | |
| 297 | + sourceKind === 'post' || | |
| 298 | + sourceKind === 'template-part' || | |
| 299 | + sourceKind === null || | |
| 300 | + (sourceKind === 'wp-navigation' && | |
| 301 | + !!target?.el?.getAttribute?.('data-extendify-part-block-id')); | |
| 269 | 302 | const aiAvailable = |
| 270 | 303 | isAgentAvailable() && |
| 271 | 304 | agentSupportedSource && |
| 272 | 305 | isAgentEligibleForTarget(target); |
| @@ -325,9 +358,9 @@ | ||
| 325 | 358 | }); |
| 326 | 359 | askAiAboutElement(el); |
| 327 | 360 | }; |
| 328 | 361 | |
| 329 | -// Test seam for the Ask AI pill's handler, which isn't exported. | |
| 362 | +// The Ask AI pill's handler, for callers holding an element, not a pill. | |
| 330 | 363 | export const askAiTarget = (el) => onAiClick(el); |
| 331 | 364 | |
| 332 | 365 | // Exported for keyboard-entry's focus-driven mount/dismiss. |
| 333 | 366 | export const showBar = (el) => renderBar(el); |
| @@ -332,9 +365,9 @@ | ||
| 332 | 365 | // Exported for keyboard-entry's focus-driven mount/dismiss. |
| 333 | 366 | export const showBar = (el) => renderBar(el); |
| 334 | 367 | export const hideBar = () => clearBar(); |
| 335 | 368 | |
| 336 | -const renderBar = (el) => { | |
| 369 | +const renderBar = (el, fromEl = el) => { | |
| 337 | 370 | // While an agent block is staged, the hover bar is intentionally |
| 338 | 371 | // hidden — only DOMHighlighter's X-close indicator is shown. |
| 339 | 372 | // Defense in depth for any caller (a re-render, the keyboard |
| 340 | 373 | // entry's showBar) that might otherwise paint a stale bar. |
| @@ -340,9 +373,9 @@ | ||
| 340 | 373 | // entry's showBar) that might otherwise paint a stale bar. |
| 341 | 374 | if (hasAgentBlockSelected()) return; |
| 342 | 375 | if (isAgentWorking()) return; |
| 343 | 376 | |
| 344 | - const target = buildTarget(el); | |
| 377 | + const target = buildTarget(el, fromEl); | |
| 345 | 378 | const { quickEditable, aiAvailable } = pillContextFor(target); |
| 346 | 379 | // Bail BEFORE clearing the current bar — when the cursor traverses |
| 347 | 380 | // from a renderable block to an UNSUPPORTED tagged ancestor (e.g. a |
| 348 | 381 | // tagged group with too many inner tagged blocks, or a tagged |
| @@ -522,9 +555,9 @@ | ||
| 522 | 555 | } |
| 523 | 556 | const el = findTagged(e.target); |
| 524 | 557 | if (el === hoverTarget) return; |
| 525 | 558 | if (!el) return; |
| 526 | - renderBar(el); | |
| 559 | + renderBar(el, e.target); | |
| 527 | 560 | }; |
| 528 | 561 | |
| 529 | 562 | const onScrollOrResize = () => { |
| 530 | 563 | if (!hoverTarget) return; |