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/QuickEdit/lib/hover-bar.js +42 -9 3.1.4 → trunk View file →
@@ -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;