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 +179 -95 3.1.1 → trunk 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 };