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 +164 -91 3.1.2 → trunk 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 };