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 +179 -95 3.1.13.2.1 View file →
@@ -1,22 +1,23 @@
1 1 // Plain-DOM (not React) — mouseover-driven, runs outside the React
2 2 // commit cycle to avoid dropped clicks under fast pointer movement.
3 +
4 +import { track } from '@shared/lib/track';
3 5 import { __ } from '@wordpress/i18n';
4 6 import { useEditModeStore } from '../state/edit-mode';
5 7 import { useQuickEditStore } from '../state/store';
6 -import { isAgentEligibleForTarget } from './agent-gate';
8 +import { whenAnimationsSettle } from './after-animations';
9 +import { escapesDynamicBlock, isAgentEligibleForTarget } from './agent-gate';
7 10 import {
8 11 askAiAboutElement,
9 12 hasAgentBlockSelected,
10 13 isAgentAvailable,
11 - isAgentSidebarOpen,
12 - stageAgentBlock,
13 14 subscribeToAgentBlock,
14 15 } from './ask-ai';
15 16 import { prefetchBlockSource } from './block-source-cache';
16 17 import { decideClickAction } from './click-rule';
17 18 import { resolveTarget } from './dom';
18 -import { track } from './insights';
19 +import { needsContrastRing } from './over-media';
19 20 import { hasQuickEditModalFor } from './quick-edit-handlers';
20 21 import { hasSaver, saveSelected } from './save-bridge';
21 22 import {
22 23 getTranslatedContext,
@@ -41,8 +42,12 @@
41 42 // Body-level fixed overlay rather than an outline on each block:
42 43 // outline overhang would clip inside an ancestor's overflow:hidden,
43 44 // and a single repositioning overlay gets the smooth-expand feel
44 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 +
45 50 const ensureOutline = () => {
46 51 if (hoverOutline) return hoverOutline;
47 52 hoverOutline = document.createElement('div');
48 53 hoverOutline.className =
@@ -56,10 +61,11 @@
56 61 // afford to animate: every scroll event would reset the transition target
57 62 // while the outline is still en route, so the outline visibly trails the
58 63 // content during a drag-scroll ("stays fixed in screen"). Hover-driven
59 64 // updates (block A → block B) keep the spring animation.
60 -const showOutline = (el, { instant = false } = {}) => {
61 - const overlay = ensureOutline();
65 +let dropSettle = () => {};
66 +
67 +const positionOutline = (overlay, el, instant) => {
62 68 if (instant) {
63 69 overlay.style.transition = 'none';
64 70 } else if (overlay.style.transition === 'none') {
65 71 overlay.style.transition = '';
@@ -64,17 +70,30 @@
64 70 } else if (overlay.style.transition === 'none') {
65 71 overlay.style.transition = '';
66 72 }
67 73 const r = el.getBoundingClientRect();
68 - overlay.style.top = `${r.top}px`;
69 - overlay.style.left = `${r.left}px`;
70 - overlay.style.width = `${r.width}px`;
71 - 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));
72 86 overlay.classList.add('is-visible');
87 + dropSettle();
88 + dropSettle = whenAnimationsSettle(el, () =>
89 + positionOutline(overlay, el, true),
90 + );
73 91 debugLog(instant ? 'showOutline (instant)' : 'showOutline', el);
74 92 };
75 93
76 94 const hideOutline = () => {
95 + dropSettle();
77 96 hoverOutline?.classList.remove('is-visible');
78 97 debugLog('hideOutline');
79 98 };
80 99
@@ -87,17 +106,37 @@
87 106 const PART_ATTR = 'data-extendify-part-block-id';
88 107 const PRODUCT_ATTR = 'data-extendify-quick-edit-product-id';
89 108 const WPFORM_FIELD_ATTR = 'data-extendify-quick-edit-wpform-field-id';
90 109 const MEDIATEXT_MEDIA_ATTR = 'data-extendify-quick-edit-mediatext-media';
110 +const PART_SLUG_ATTR = 'data-extendify-part-slug';
91 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 +
92 120 // Resolve the live DOM node for the currently-staged agent block, so the
93 121 // click + hover gates can carve out "inside the staged block." Returns
94 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.
95 125 const stagedBlockEl = () => {
96 126 const block = useQuickEditStore.getState().agentBlock;
97 127 if (!block?.id) return null;
98 128 const attr = block.target || POST_ATTR;
99 - 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;
100 139 };
101 140
102 141 // Resolve the committed selection's live DOM node. buildTarget stashes
103 142 // the element reference on the descriptor; if the block has been swapped
@@ -200,9 +239,9 @@
200 239 // a cover's inner-container) so the bar resolves to the nearest editable
201 240 // parent. Without this, hovering the middle of a hero cover that
202 241 // surfaces post-title returned blockType=null and — combined with the
203 242 // template-part source gating Ask AI off — produced no bar at all.
204 -const buildTarget = (el) => {
243 +const buildTarget = (el, fromEl = el) => {
205 244 let current = resolveTarget(el);
206 245 let safety = 5;
207 246 while (
208 247 current &&
@@ -213,8 +252,11 @@
213 252 const next = resolveTarget(current.el.parentElement);
214 253 if (!next) return current;
215 254 current = next;
216 255 }
256 + if (current && escapesDynamicBlock(fromEl, current.el)) {
257 + return { ...current, dynamicInterior: true };
258 + }
217 259 return current;
218 260 };
219 261
220 262 // Picker blocks keep the bar visible because the dropdown anchors
@@ -233,18 +275,31 @@
233 275 // pill shows the error inline instead, and Ask AI stays reachable.
234 276 const isTranslatedTextBlock = (target) =>
235 277 isTranslatedRender() && isTextBearing(target?.blockType);
236 278
237 -// Which pills a target would surface (without mounting the bar). Click rule
238 -// (Option 7) needs this to decide between opening QE directly, today's
239 -// sticky commit, and the silent agent stage. Exported so keyboard-entry
240 -// 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.
241 282 export const pillContextFor = (target) => {
242 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);
243 289 const quickEditable =
244 - quickEditEnabled && hasQuickEditModalFor(target?.blockType);
290 + quickEditEnabled &&
291 + hasQuickEditModalFor(target?.blockType) &&
292 + !isSyncedPatternId(compositeId);
245 293 const sourceKind = target?.source?.kind ?? null;
246 - 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'));
247 302 const aiAvailable =
248 303 isAgentAvailable() &&
249 304 agentSupportedSource &&
250 305 isAgentEligibleForTarget(target);
@@ -250,9 +305,9 @@
250 305 isAgentEligibleForTarget(target);
251 306 return { quickEditable, aiAvailable };
252 307 };
253 308
254 -// 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.
255 310 export const editTarget = (target) => onEditClick(target);
256 311
257 312 const onEditClick = (target) => {
258 313 const store = useQuickEditStore.getState();
@@ -272,12 +327,20 @@
272 327 // Snapshot the bar rect before clearing — ImagePicker anchors to it.
273 328 const anchorRect = hoverBar?.getBoundingClientRect() ?? null;
274 329 const placement = hoverBar?.dataset.extendifyQuickEditPlacement ?? 'above';
275 330
276 - if (!isPickerType(target.blockType)) clearBar();
331 + const isPicker = isPickerType(target.blockType);
332 + if (!isPicker) clearBar();
333 + // Order matters: clearing the commit first drops a picker's anchor bar.
334 + store.setSelected({ ...target, anchorRect, anchorPlacement: placement });
277 335 store.setCommittedSelection(null);
278 - store.setSelected({ ...target, anchorRect, anchorPlacement: placement });
279 - track('quick_edit_clicked', { blockType: target.blockType });
336 +
337 + if (!isPicker) {
338 + track('quick_edit_action', {
339 + element: target.blockType,
340 + type: 'quick_edit',
341 + });
342 + }
280 343 };
281 344
282 345 const onAiClick = (el) => {
283 346 // clearSelected before clearBar: when QE was clicked first on a
@@ -288,14 +351,16 @@
288 351 const store = useQuickEditStore.getState();
289 352 store.clearSelected();
290 353 clearBar();
291 354 store.setCommittedSelection(null);
292 - track('ask_ai_clicked', { matched: !!resolveTarget(el)?.blockType });
355 + track('quick_edit_action', {
356 + element: resolveTarget(el)?.blockType ?? null,
357 + type: 'ask_ai',
358 + });
293 359 askAiAboutElement(el);
294 360 };
295 361
296 -// Exported for keyboard-entry to route Enter on an Ask-AI-only block
297 -// 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.
298 363 export const askAiTarget = (el) => onAiClick(el);
299 364
300 365 // Exported for keyboard-entry's focus-driven mount/dismiss.
301 366 export const showBar = (el) => renderBar(el);
@@ -300,16 +365,17 @@
300 365 // Exported for keyboard-entry's focus-driven mount/dismiss.
301 366 export const showBar = (el) => renderBar(el);
302 367 export const hideBar = () => clearBar();
303 368
304 -const renderBar = (el) => {
369 +const renderBar = (el, fromEl = el) => {
305 370 // While an agent block is staged, the hover bar is intentionally
306 371 // hidden — only DOMHighlighter's X-close indicator is shown.
307 372 // Defense in depth for any caller (a re-render, the keyboard
308 373 // entry's showBar) that might otherwise paint a stale bar.
309 374 if (hasAgentBlockSelected()) return;
375 + if (isAgentWorking()) return;
310 376
311 - const target = buildTarget(el);
377 + const target = buildTarget(el, fromEl);
312 378 const { quickEditable, aiAvailable } = pillContextFor(target);
313 379 // Bail BEFORE clearing the current bar — when the cursor traverses
314 380 // from a renderable block to an UNSUPPORTED tagged ancestor (e.g. a
315 381 // tagged group with too many inner tagged blocks, or a tagged
@@ -397,13 +463,58 @@
397 463 });
398 464 bar.appendChild(aiBtn);
399 465 }
400 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 +
401 483 document.body.appendChild(bar);
402 484 hoverBar = bar;
485 + syncDismissState();
403 486 positionBar(bar, anchorEl);
404 487 };
405 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 +
406 517 // Walk up to the innermost tagged ancestor. resolveTarget then derives
407 518 // blockType from that element's wp-block-* class; the pill renderer
408 519 // decides which pills (if any) to show. Lighter than resolveTarget —
409 520 // onMouseOver hot path doesn't need the full descriptor.
@@ -426,8 +537,9 @@
426 537
427 538 const onMouseOver = (e) => {
428 539 if (!useEditModeStore.getState().on) return;
429 540 if (useQuickEditStore.getState().selected) return;
541 + if (isAgentWorking()) return;
430 542 // Sticky modes hard-suppress all hover-driven bar movement.
431 543 // - agentBlock staged: the bar is intentionally hidden; only
432 544 // DOMHighlighter's X-close is shown. To re-engage Ask AI on the
433 545 // same block, the user clicks X-close (clears agentBlock) then
@@ -443,9 +555,9 @@
443 555 }
444 556 const el = findTagged(e.target);
445 557 if (el === hoverTarget) return;
446 558 if (!el) return;
447 - renderBar(el);
559 + renderBar(el, e.target);
448 560 };
449 561
450 562 const onScrollOrResize = () => {
451 563 if (!hoverTarget) return;
@@ -492,24 +604,18 @@
492 604
493 605 const onDocClickCapture = (e) => {
494 606 if (!useEditModeStore.getState().on) return;
495 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;
496 610
497 611 // Implicit close on the QE text-edit canvas: clicks outside the canvas
498 612 // while it's open save the in-flight edits instead of discarding them.
499 - // `alsoClear: false` only when the click will open QE on a different
500 - // block (the `select` branch's `quickEditable` cell); otherwise save
501 - // clears so the canvas unmounts. Without that distinction the click
502 - // would race: save's `clearSelected(null)` would overwrite the new
503 - // block's `setSelected(B)`. `hasSaver()` is false for picker blocks
504 - // (image / cover) — they save synchronously on pick and never
505 - // register. Fall through either way so the existing agentBlock-clear +
506 - // 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.
507 616 if (hasSaver() && useQuickEditStore.getState().selected) {
508 - const tagged = findTagged(e.target);
509 - const willOpenQE =
510 - !!tagged && hasQuickEditModalFor(buildTarget(tagged)?.blockType);
511 - saveSelected({ alsoClear: !willOpenQE });
617 + saveSelected();
512 618 }
513 619
514 620 // Soft selection: while a block is staged for Ask AI, clicks INSIDE
515 621 // the staged block route natively (anchor navigates, form control
@@ -559,64 +665,23 @@
559 665 switch (result.action) {
560 666 case 'select': {
561 667 e.preventDefault();
562 668 e.stopPropagation();
563 - // stopPropagation above blocks ImagePicker's bubble-phase
564 - // outside-click — without this clear its menu lingers (issue 19).
565 669 const store = useQuickEditStore.getState();
566 - if (
567 - store.selected &&
568 - isPickerType(store.selected.blockType) &&
569 - store.selected.el !== result.el
570 - ) {
670 + // Only pickers reach this — other canvases cover the block they
671 + // opened on.
672 + if (store.selected?.el === result.el) {
571 673 store.clearSelected();
572 - }
573 - const target = buildTarget(result.el);
574 - const { quickEditable, aiAvailable } = pillContextFor(target);
575 -
576 - // Click semantics by pill count + agent-open state:
577 - // QE-only → open QE menu directly (collapsed gesture).
578 - // AI-only + closed → today's sticky commit (the one path that
579 - // keeps committedSelection alive).
580 - // AI-only + open → silently stage agentBlock (bridge).
581 - // Both pills → open QE menu directly; bridge agentBlock
582 - // too when the agent sidebar is open. The
583 - // Ask AI button now lives on the QE bar
584 - // chrome (BlockTextEditor.jsx), so the
585 - // collapsed click no longer hides Ask AI.
586 - // Picker-type blocks (image, cover) are
587 - // exempt from the silent stage — the
588 - // hover bar stays mounted for them and
589 - // keeps the Ask AI pill, so the user
590 - // escalates explicitly rather than seeing
591 - // both the picker dropdown AND the
592 - // agent's X-close at once.
593 - // Tagged but neither → clear (no outline on a block the user
594 - // can't act on).
595 - if (quickEditable) {
596 - renderBar(result.el);
597 - onEditClick(target);
598 - if (
599 - aiAvailable &&
600 - isAgentSidebarOpen() &&
601 - !isPickerType(target.blockType)
602 - ) {
603 - stageAgentBlock(result.el);
604 - }
605 - return;
606 - }
607 - if (aiAvailable && isAgentSidebarOpen()) {
608 - useQuickEditStore.getState().setCommittedSelection(null);
674 + store.setCommittedSelection(null);
609 675 clearBar();
610 - stageAgentBlock(result.el);
611 676 return;
612 677 }
613 - if (aiAvailable) {
614 - useQuickEditStore.getState().setCommittedSelection(target);
615 - renderBar(result.el);
616 - 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();
617 682 }
618 - clearBar();
683 + pinTarget(result.el);
619 684 return;
620 685 }
621 686 case 'clear':
622 687 clearBar();
@@ -628,8 +693,9 @@
628 693
629 694 let unsubEditMode = null;
630 695 let unsubSelected = null;
631 696 let unsubAgentBlock = null;
697 +let workingObserver = null;
632 698 let unsubCommitted = null;
633 699
634 700 export const attach = () => {
635 701 if (attached) return;
@@ -637,12 +703,8 @@
637 703 document.addEventListener('mouseover', onMouseOver, true);
638 704 window.addEventListener('scroll', onScrollOrResize, true);
639 705 window.addEventListener('resize', onScrollOrResize);
640 706 document.addEventListener('click', onDocClickCapture, true);
641 - // Warm the agent-sidebar state cache so the sync click rule has fresh
642 - // state by the time the user clicks. The dynamic import resolves on
643 - // the microtask queue; user clicks are seconds-later in real use.
644 - isAgentSidebarOpen();
645 707
646 708 unsubEditMode = useEditModeStore.subscribe((state) => {
647 709 if (!state.on) {
648 710 useQuickEditStore.getState().setCommittedSelection(null);
@@ -657,9 +719,12 @@
657 719 let lastCommitted = useQuickEditStore.getState().committedSelection;
658 720 unsubCommitted = useQuickEditStore.subscribe((state) => {
659 721 const prev = lastCommitted;
660 722 lastCommitted = state.committedSelection;
661 - 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();
662 727 });
663 728 // Picker dropdown anchors to the bar; keep it visible for those.
664 729 // On the non-null → null transition (Esc / Cancel / Save closes the
665 730 // canvas) the bar is re-rendered on the previously edited element
@@ -699,13 +764,30 @@
699 764 }
700 765 if (!prev?.el || !document.body.contains(prev.el)) return;
701 766 if (!useEditModeStore.getState().on) return;
702 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;
703 771 renderBar(prev.el);
704 772 });
705 773 unsubAgentBlock = subscribeToAgentBlock((hasBlock) => {
706 774 if (hasBlock) clearBar();
707 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 + }
708 790 };
709 791
710 792 export const detach = () => {
711 793 if (!attached) return;
@@ -716,12 +798,14 @@
716 798 document.removeEventListener('click', onDocClickCapture, true);
717 799 unsubEditMode?.();
718 800 unsubSelected?.();
719 801 unsubAgentBlock?.();
802 + workingObserver?.disconnect();
720 803 unsubCommitted?.();
721 804 unsubEditMode = null;
722 805 unsubSelected = null;
723 806 unsubAgentBlock = null;
807 + workingObserver = null;
724 808 unsubCommitted = null;
725 809 clearBar();
726 810 removeOutline();
727 811 };