PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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 +164 -91 3.1.33.2.1 View file →
@@ -4,20 +4,20 @@
4 4 import { track } from '@shared/lib/track';
5 5 import { __ } from '@wordpress/i18n';
6 6 import { useEditModeStore } from '../state/edit-mode';
7 7 import { useQuickEditStore } from '../state/store';
8 -import { isAgentEligibleForTarget } from './agent-gate';
8 +import { whenAnimationsSettle } from './after-animations';
9 +import { escapesDynamicBlock, isAgentEligibleForTarget } from './agent-gate';
9 10 import {
10 11 askAiAboutElement,
11 12 hasAgentBlockSelected,
12 13 isAgentAvailable,
13 - isAgentSidebarOpen,
14 - stageAgentBlock,
15 14 subscribeToAgentBlock,
16 15 } from './ask-ai';
17 16 import { prefetchBlockSource } from './block-source-cache';
18 17 import { decideClickAction } from './click-rule';
19 18 import { resolveTarget } from './dom';
19 +import { needsContrastRing } from './over-media';
20 20 import { hasQuickEditModalFor } from './quick-edit-handlers';
21 21 import { hasSaver, saveSelected } from './save-bridge';
22 22 import {
23 23 getTranslatedContext,
@@ -42,8 +42,12 @@
42 42 // Body-level fixed overlay rather than an outline on each block:
43 43 // outline overhang would clip inside an ancestor's overflow:hidden,
44 44 // and a single repositioning overlay gets the smooth-expand feel
45 45 // for free via CSS transitions.
46 +// Must match DOMHighlighter's minimum, or the outline resizes on click.
47 +const MIN_OUTLINE_SIZE = 10;
48 +const framed = (size) => Math.max(size, MIN_OUTLINE_SIZE);
49 +
46 50 const ensureOutline = () => {
47 51 if (hoverOutline) return hoverOutline;
48 52 hoverOutline = document.createElement('div');
49 53 hoverOutline.className =
@@ -57,10 +61,11 @@
57 61 // afford to animate: every scroll event would reset the transition target
58 62 // while the outline is still en route, so the outline visibly trails the
59 63 // content during a drag-scroll ("stays fixed in screen"). Hover-driven
60 64 // updates (block A → block B) keep the spring animation.
61 -const showOutline = (el, { instant = false } = {}) => {
62 - const overlay = ensureOutline();
65 +let dropSettle = () => {};
66 +
67 +const positionOutline = (overlay, el, instant) => {
63 68 if (instant) {
64 69 overlay.style.transition = 'none';
65 70 } else if (overlay.style.transition === 'none') {
66 71 overlay.style.transition = '';
@@ -65,17 +70,30 @@
65 70 } else if (overlay.style.transition === 'none') {
66 71 overlay.style.transition = '';
67 72 }
68 73 const r = el.getBoundingClientRect();
69 - overlay.style.top = `${r.top}px`;
70 - overlay.style.left = `${r.left}px`;
71 - overlay.style.width = `${r.width}px`;
72 - overlay.style.height = `${r.height}px`;
74 + const width = framed(r.width);
75 + const height = framed(r.height);
76 + overlay.style.top = `${r.top - (height - r.height) / 2}px`;
77 + overlay.style.left = `${r.left - (width - r.width) / 2}px`;
78 + overlay.style.width = `${width}px`;
79 + overlay.style.height = `${height}px`;
80 +};
81 +
82 +const showOutline = (el, { instant = false } = {}) => {
83 + const overlay = ensureOutline();
84 + positionOutline(overlay, el, instant);
85 + overlay.classList.toggle('is-over-media', needsContrastRing(el));
73 86 overlay.classList.add('is-visible');
87 + dropSettle();
88 + dropSettle = whenAnimationsSettle(el, () =>
89 + positionOutline(overlay, el, true),
90 + );
74 91 debugLog(instant ? 'showOutline (instant)' : 'showOutline', el);
75 92 };
76 93
77 94 const hideOutline = () => {
95 + dropSettle();
78 96 hoverOutline?.classList.remove('is-visible');
79 97 debugLog('hideOutline');
80 98 };
81 99
@@ -88,17 +106,37 @@
88 106 const PART_ATTR = 'data-extendify-part-block-id';
89 107 const PRODUCT_ATTR = 'data-extendify-quick-edit-product-id';
90 108 const WPFORM_FIELD_ATTR = 'data-extendify-quick-edit-wpform-field-id';
91 109 const MEDIATEXT_MEDIA_ATTR = 'data-extendify-quick-edit-mediatext-media';
110 +const PART_SLUG_ATTR = 'data-extendify-part-slug';
92 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 +
116 +// DOMHighlighter's class; a sync listener can't read a React prop.
117 +export const isAgentWorking = () =>
118 + !!document.querySelector('.wp-site-blocks.extendify-agent-working');
119 +
93 120 // Resolve the live DOM node for the currently-staged agent block, so the
94 121 // click + hover gates can carve out "inside the staged block." Returns
95 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.
96 125 const stagedBlockEl = () => {
97 126 const block = useQuickEditStore.getState().agentBlock;
98 127 if (!block?.id) return null;
99 128 const attr = block.target || POST_ATTR;
100 - 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;
101 139 };
102 140
103 141 // Resolve the committed selection's live DOM node. buildTarget stashes
104 142 // the element reference on the descriptor; if the block has been swapped
@@ -201,9 +239,9 @@
201 239 // a cover's inner-container) so the bar resolves to the nearest editable
202 240 // parent. Without this, hovering the middle of a hero cover that
203 241 // surfaces post-title returned blockType=null and — combined with the
204 242 // template-part source gating Ask AI off — produced no bar at all.
205 -const buildTarget = (el) => {
243 +const buildTarget = (el, fromEl = el) => {
206 244 let current = resolveTarget(el);
207 245 let safety = 5;
208 246 while (
209 247 current &&
@@ -214,8 +252,11 @@
214 252 const next = resolveTarget(current.el.parentElement);
215 253 if (!next) return current;
216 254 current = next;
217 255 }
256 + if (current && escapesDynamicBlock(fromEl, current.el)) {
257 + return { ...current, dynamicInterior: true };
258 + }
218 259 return current;
219 260 };
220 261
221 262 // Picker blocks keep the bar visible because the dropdown anchors
@@ -234,18 +275,31 @@
234 275 // pill shows the error inline instead, and Ask AI stays reachable.
235 276 const isTranslatedTextBlock = (target) =>
236 277 isTranslatedRender() && isTextBearing(target?.blockType);
237 278
238 -// Which pills a target would surface (without mounting the bar). Click rule
239 -// (Option 7) needs this to decide between opening QE directly, today's
240 -// sticky commit, and the silent agent stage. Exported so keyboard-entry
241 -// gates Enter on the same signal the hover bar uses.
279 +// Which pills a target would surface (without mounting the bar). A target
280 +// with no pills isn't worth pinning. Exported so keyboard-entry gates Enter
281 +// on the same signal the hover bar uses.
242 282 export const pillContextFor = (target) => {
243 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);
244 289 const quickEditable =
245 - quickEditEnabled && hasQuickEditModalFor(target?.blockType);
290 + quickEditEnabled &&
291 + hasQuickEditModalFor(target?.blockType) &&
292 + !isSyncedPatternId(compositeId);
246 293 const sourceKind = target?.source?.kind ?? null;
247 - 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'));
248 302 const aiAvailable =
249 303 isAgentAvailable() &&
250 304 agentSupportedSource &&
251 305 isAgentEligibleForTarget(target);
@@ -251,9 +305,9 @@
251 305 isAgentEligibleForTarget(target);
252 306 return { quickEditable, aiAvailable };
253 307 };
254 308
255 -// Exported for keyboard-entry to bypass the bar's click handler.
309 +// Test seam for the Quick Edit pill's handler, which isn't exported.
256 310 export const editTarget = (target) => onEditClick(target);
257 311
258 312 const onEditClick = (target) => {
259 313 const store = useQuickEditStore.getState();
@@ -275,10 +329,11 @@
275 329 const placement = hoverBar?.dataset.extendifyQuickEditPlacement ?? 'above';
276 330
277 331 const isPicker = isPickerType(target.blockType);
278 332 if (!isPicker) clearBar();
333 + // Order matters: clearing the commit first drops a picker's anchor bar.
334 + store.setSelected({ ...target, anchorRect, anchorPlacement: placement });
279 335 store.setCommittedSelection(null);
280 - store.setSelected({ ...target, anchorRect, anchorPlacement: placement });
281 336
282 337 if (!isPicker) {
283 338 track('quick_edit_action', {
284 339 element: target.blockType,
@@ -303,10 +358,9 @@
303 358 });
304 359 askAiAboutElement(el);
305 360 };
306 361
307 -// Exported for keyboard-entry to route Enter on an Ask-AI-only block
308 -// straight to the agent, mirroring the Ask AI pill's click handler.
362 +// The Ask AI pill's handler, for callers holding an element, not a pill.
309 363 export const askAiTarget = (el) => onAiClick(el);
310 364
311 365 // Exported for keyboard-entry's focus-driven mount/dismiss.
312 366 export const showBar = (el) => renderBar(el);
@@ -311,16 +365,17 @@
311 365 // Exported for keyboard-entry's focus-driven mount/dismiss.
312 366 export const showBar = (el) => renderBar(el);
313 367 export const hideBar = () => clearBar();
314 368
315 -const renderBar = (el) => {
369 +const renderBar = (el, fromEl = el) => {
316 370 // While an agent block is staged, the hover bar is intentionally
317 371 // hidden — only DOMHighlighter's X-close indicator is shown.
318 372 // Defense in depth for any caller (a re-render, the keyboard
319 373 // entry's showBar) that might otherwise paint a stale bar.
320 374 if (hasAgentBlockSelected()) return;
375 + if (isAgentWorking()) return;
321 376
322 - const target = buildTarget(el);
377 + const target = buildTarget(el, fromEl);
323 378 const { quickEditable, aiAvailable } = pillContextFor(target);
324 379 // Bail BEFORE clearing the current bar — when the cursor traverses
325 380 // from a renderable block to an UNSUPPORTED tagged ancestor (e.g. a
326 381 // tagged group with too many inner tagged blocks, or a tagged
@@ -408,13 +463,58 @@
408 463 });
409 464 bar.appendChild(aiBtn);
410 465 }
411 466
467 + const closeBtn = document.createElement('button');
468 + closeBtn.type = 'button';
469 + closeBtn.className =
470 + 'extendify-quick-edit-pill extendify-quick-edit-pill-close';
471 + closeBtn.setAttribute('data-extendify-quick-edit-pill', '');
472 + closeBtn.setAttribute('aria-label', __('Dismiss', 'extendify-local'));
473 + closeBtn.innerHTML = '<span aria-hidden="true">✕</span>';
474 + closeBtn.addEventListener('mousedown', stopMouseDown);
475 + closeBtn.addEventListener('click', (ev) => {
476 + ev.preventDefault();
477 + ev.stopPropagation();
478 + useQuickEditStore.getState().setCommittedSelection(null);
479 + clearBar();
480 + });
481 + bar.appendChild(closeBtn);
482 +
412 483 document.body.appendChild(bar);
413 484 hoverBar = bar;
485 + syncDismissState();
414 486 positionBar(bar, anchorEl);
415 487 };
416 488
489 +// Nothing to dismiss until a click pins the bar; hover ends with the pointer.
490 +const syncDismissState = () => {
491 + const closeBtn = hoverBar?.querySelector('.extendify-quick-edit-pill-close');
492 + if (!closeBtn) return;
493 + closeBtn.disabled = !useQuickEditStore.getState().committedSelection;
494 +};
495 +
496 +// preventScroll: the pill is already on screen; scrolling to it jumps the page.
497 +const focusFirstPill = () => {
498 + hoverBar
499 + ?.querySelector('.extendify-quick-edit-pill')
500 + ?.focus({ preventScroll: true });
501 +};
502 +
503 +export const pinTarget = (el, target = buildTarget(el)) => {
504 + const { quickEditable, aiAvailable } = pillContextFor(target);
505 + const store = useQuickEditStore.getState();
506 + if (!quickEditable && !aiAvailable) {
507 + store.setCommittedSelection(null);
508 + clearBar();
509 + return;
510 + }
511 + renderBar(el);
512 + store.setCommittedSelection(target);
513 + syncDismissState();
514 + focusFirstPill();
515 +};
516 +
417 517 // Walk up to the innermost tagged ancestor. resolveTarget then derives
418 518 // blockType from that element's wp-block-* class; the pill renderer
419 519 // decides which pills (if any) to show. Lighter than resolveTarget —
420 520 // onMouseOver hot path doesn't need the full descriptor.
@@ -437,8 +537,9 @@
437 537
438 538 const onMouseOver = (e) => {
439 539 if (!useEditModeStore.getState().on) return;
440 540 if (useQuickEditStore.getState().selected) return;
541 + if (isAgentWorking()) return;
441 542 // Sticky modes hard-suppress all hover-driven bar movement.
442 543 // - agentBlock staged: the bar is intentionally hidden; only
443 544 // DOMHighlighter's X-close is shown. To re-engage Ask AI on the
444 545 // same block, the user clicks X-close (clears agentBlock) then
@@ -454,9 +555,9 @@
454 555 }
455 556 const el = findTagged(e.target);
456 557 if (el === hoverTarget) return;
457 558 if (!el) return;
458 - renderBar(el);
559 + renderBar(el, e.target);
459 560 };
460 561
461 562 const onScrollOrResize = () => {
462 563 if (!hoverTarget) return;
@@ -503,24 +604,18 @@
503 604
504 605 const onDocClickCapture = (e) => {
505 606 if (!useEditModeStore.getState().on) return;
506 607 if (e.target?.closest?.(QE_INTERIOR)) return;
608 + // Below QE_INTERIOR, or a run also deadens the sidebar and canvas.
609 + if (isAgentWorking()) return;
507 610
508 611 // Implicit close on the QE text-edit canvas: clicks outside the canvas
509 612 // while it's open save the in-flight edits instead of discarding them.
510 - // `alsoClear: false` only when the click will open QE on a different
511 - // block (the `select` branch's `quickEditable` cell); otherwise save
512 - // clears so the canvas unmounts. Without that distinction the click
513 - // would race: save's `clearSelected(null)` would overwrite the new
514 - // block's `setSelected(B)`. `hasSaver()` is false for picker blocks
515 - // (image / cover) — they save synchronously on pick and never
516 - // register. Fall through either way so the existing agentBlock-clear +
517 - // clear-bar branches still run.
613 + // `hasSaver()` is false for picker blocks (image / cover) — they save
614 + // synchronously on pick and never register. Fall through either way so
615 + // the existing agentBlock-clear + clear-bar branches still run.
518 616 if (hasSaver() && useQuickEditStore.getState().selected) {
519 - const tagged = findTagged(e.target);
520 - const willOpenQE =
521 - !!tagged && hasQuickEditModalFor(buildTarget(tagged)?.blockType);
522 - saveSelected({ alsoClear: !willOpenQE });
617 + saveSelected();
523 618 }
524 619
525 620 // Soft selection: while a block is staged for Ask AI, clicks INSIDE
526 621 // the staged block route natively (anchor navigates, form control
@@ -570,64 +665,23 @@
570 665 switch (result.action) {
571 666 case 'select': {
572 667 e.preventDefault();
573 668 e.stopPropagation();
574 - // stopPropagation above blocks ImagePicker's bubble-phase
575 - // outside-click — without this clear its menu lingers (issue 19).
576 669 const store = useQuickEditStore.getState();
577 - if (
578 - store.selected &&
579 - isPickerType(store.selected.blockType) &&
580 - store.selected.el !== result.el
581 - ) {
670 + // Only pickers reach this — other canvases cover the block they
671 + // opened on.
672 + if (store.selected?.el === result.el) {
582 673 store.clearSelected();
583 - }
584 - const target = buildTarget(result.el);
585 - const { quickEditable, aiAvailable } = pillContextFor(target);
586 -
587 - // Click semantics by pill count + agent-open state:
588 - // QE-only → open QE menu directly (collapsed gesture).
589 - // AI-only + closed → today's sticky commit (the one path that
590 - // keeps committedSelection alive).
591 - // AI-only + open → silently stage agentBlock (bridge).
592 - // Both pills → open QE menu directly; bridge agentBlock
593 - // too when the agent sidebar is open. The
594 - // Ask AI button now lives on the QE bar
595 - // chrome (BlockTextEditor.jsx), so the
596 - // collapsed click no longer hides Ask AI.
597 - // Picker-type blocks (image, cover) are
598 - // exempt from the silent stage — the
599 - // hover bar stays mounted for them and
600 - // keeps the Ask AI pill, so the user
601 - // escalates explicitly rather than seeing
602 - // both the picker dropdown AND the
603 - // agent's X-close at once.
604 - // Tagged but neither → clear (no outline on a block the user
605 - // can't act on).
606 - if (quickEditable) {
607 - renderBar(result.el);
608 - onEditClick(target);
609 - if (
610 - aiAvailable &&
611 - isAgentSidebarOpen() &&
612 - !isPickerType(target.blockType)
613 - ) {
614 - stageAgentBlock(result.el);
615 - }
616 - return;
617 - }
618 - if (aiAvailable && isAgentSidebarOpen()) {
619 - useQuickEditStore.getState().setCommittedSelection(null);
674 + store.setCommittedSelection(null);
620 675 clearBar();
621 - stageAgentBlock(result.el);
622 676 return;
623 677 }
624 - if (aiAvailable) {
625 - useQuickEditStore.getState().setCommittedSelection(target);
626 - renderBar(result.el);
627 - return;
678 + // stopPropagation above blocks ImagePicker's bubble-phase
679 + // outside-click — without this clear its menu lingers (issue 19).
680 + if (store.selected && isPickerType(store.selected.blockType)) {
681 + store.clearSelected();
628 682 }
629 - clearBar();
683 + pinTarget(result.el);
630 684 return;
631 685 }
632 686 case 'clear':
633 687 clearBar();
@@ -639,8 +693,9 @@
639 693
640 694 let unsubEditMode = null;
641 695 let unsubSelected = null;
642 696 let unsubAgentBlock = null;
697 +let workingObserver = null;
643 698 let unsubCommitted = null;
644 699
645 700 export const attach = () => {
646 701 if (attached) return;
@@ -648,12 +703,8 @@
648 703 document.addEventListener('mouseover', onMouseOver, true);
649 704 window.addEventListener('scroll', onScrollOrResize, true);
650 705 window.addEventListener('resize', onScrollOrResize);
651 706 document.addEventListener('click', onDocClickCapture, true);
652 - // Warm the agent-sidebar state cache so the sync click rule has fresh
653 - // state by the time the user clicks. The dynamic import resolves on
654 - // the microtask queue; user clicks are seconds-later in real use.
655 - isAgentSidebarOpen();
656 707
657 708 unsubEditMode = useEditModeStore.subscribe((state) => {
658 709 if (!state.on) {
659 710 useQuickEditStore.getState().setCommittedSelection(null);
@@ -668,9 +719,12 @@
668 719 let lastCommitted = useQuickEditStore.getState().committedSelection;
669 720 unsubCommitted = useQuickEditStore.subscribe((state) => {
670 721 const prev = lastCommitted;
671 722 lastCommitted = state.committedSelection;
672 - if (prev && !state.committedSelection) clearBar();
723 + if (!prev || state.committedSelection) return;
724 + // A picker dropdown anchors to the bar; clearing here orphans it.
725 + if (isPickerType(state.selected?.blockType)) return;
726 + clearBar();
673 727 });
674 728 // Picker dropdown anchors to the bar; keep it visible for those.
675 729 // On the non-null → null transition (Esc / Cancel / Save closes the
676 730 // canvas) the bar is re-rendered on the previously edited element
@@ -710,13 +764,30 @@
710 764 }
711 765 if (!prev?.el || !document.body.contains(prev.el)) return;
712 766 if (!useEditModeStore.getState().on) return;
713 767 if (hoverTarget === prev.el && hoverBar) return;
768 + // A save resolves after a newer pin; re-rendering here steals its bar.
769 + const committed = useQuickEditStore.getState().committedSelection;
770 + if (committed?.el && committed.el !== prev.el) return;
714 771 renderBar(prev.el);
715 772 });
716 773 unsubAgentBlock = subscribeToAgentBlock((hasBlock) => {
717 774 if (hasBlock) clearBar();
718 775 });
776 + // A bar mounted before the run began stays clickable otherwise.
777 + workingObserver = new MutationObserver(() => {
778 + if (!isAgentWorking()) return;
779 + // Otherwise hover stays suppressed after the workflow ends.
780 + useQuickEditStore.getState().setCommittedSelection(null);
781 + clearBar();
782 + });
783 + const root = document.querySelector('.wp-site-blocks');
784 + if (root) {
785 + workingObserver.observe(root, {
786 + attributes: true,
787 + attributeFilter: ['class'],
788 + });
789 + }
719 790 };
720 791
721 792 export const detach = () => {
722 793 if (!attached) return;
@@ -727,12 +798,14 @@
727 798 document.removeEventListener('click', onDocClickCapture, true);
728 799 unsubEditMode?.();
729 800 unsubSelected?.();
730 801 unsubAgentBlock?.();
802 + workingObserver?.disconnect();
731 803 unsubCommitted?.();
732 804 unsubEditMode = null;
733 805 unsubSelected = null;
734 806 unsubAgentBlock = null;
807 + workingObserver = null;
735 808 unsubCommitted = null;
736 809 clearBar();
737 810 removeOutline();
738 811 };