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
extendify / src / QuickEdit / lib / hover-bar.js

hover-bar.js in Extendify 3.2.1, at src/QuickEdit/lib/hover-bar.js

812 lines 30.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // Plain-DOM (not React) — mouseover-driven, runs outside the React
2 // commit cycle to avoid dropped clicks under fast pointer movement.
3
4 import { track } from '@shared/lib/track';
5 import { __ } from '@wordpress/i18n';
6 import { useEditModeStore } from '../state/edit-mode';
7 import { useQuickEditStore } from '../state/store';
8 import { whenAnimationsSettle } from './after-animations';
9 import { escapesDynamicBlock, isAgentEligibleForTarget } from './agent-gate';
10 import {
11 askAiAboutElement,
12 hasAgentBlockSelected,
13 isAgentAvailable,
14 subscribeToAgentBlock,
15 } from './ask-ai';
16 import { prefetchBlockSource } from './block-source-cache';
17 import { decideClickAction } from './click-rule';
18 import { resolveTarget } from './dom';
19 import { needsContrastRing } from './over-media';
20 import { hasQuickEditModalFor } from './quick-edit-handlers';
21 import { hasSaver, saveSelected } from './save-bridge';
22 import {
23 getTranslatedContext,
24 isTextBearing,
25 isTranslatedRender,
26 translatedNoticeMessage,
27 } from './translated';
28
29 let hoverTarget = null;
30 let hoverBar = null;
31 let hoverOutline = null; // body-level positioned div, see ensureOutline()
32 let attached = false;
33
34 const debugLog = (label, el) => {
35 if (!window.extQuickEditData?.debug) return;
36 console.groupCollapsed(`[qe-debug] hover-bar: ${label}`);
37 if (el) console.log('target:', el);
38 console.trace();
39 console.groupEnd();
40 };
41
42 // Body-level fixed overlay rather than an outline on each block:
43 // outline overhang would clip inside an ancestor's overflow:hidden,
44 // and a single repositioning overlay gets the smooth-expand feel
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
50 const ensureOutline = () => {
51 if (hoverOutline) return hoverOutline;
52 hoverOutline = document.createElement('div');
53 hoverOutline.className =
54 'extendify-quick-edit extendify-quick-edit-hover-outline';
55 hoverOutline.setAttribute('aria-hidden', 'true');
56 document.body.appendChild(hoverOutline);
57 return hoverOutline;
58 };
59
60 // `instant` skips the 0.2s CSS transition. Scroll-driven updates can't
61 // afford to animate: every scroll event would reset the transition target
62 // while the outline is still en route, so the outline visibly trails the
63 // content during a drag-scroll ("stays fixed in screen"). Hover-driven
64 // updates (block A → block B) keep the spring animation.
65 let dropSettle = () => {};
66
67 const positionOutline = (overlay, el, instant) => {
68 if (instant) {
69 overlay.style.transition = 'none';
70 } else if (overlay.style.transition === 'none') {
71 overlay.style.transition = '';
72 }
73 const r = el.getBoundingClientRect();
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));
86 overlay.classList.add('is-visible');
87 dropSettle();
88 dropSettle = whenAnimationsSettle(el, () =>
89 positionOutline(overlay, el, true),
90 );
91 debugLog(instant ? 'showOutline (instant)' : 'showOutline', el);
92 };
93
94 const hideOutline = () => {
95 dropSettle();
96 hoverOutline?.classList.remove('is-visible');
97 debugLog('hideOutline');
98 };
99
100 const removeOutline = () => {
101 hoverOutline?.remove();
102 hoverOutline = null;
103 };
104
105 const POST_ATTR = 'data-extendify-agent-block-id';
106 const PART_ATTR = 'data-extendify-part-block-id';
107 const PRODUCT_ATTR = 'data-extendify-quick-edit-product-id';
108 const WPFORM_FIELD_ATTR = 'data-extendify-quick-edit-wpform-field-id';
109 const MEDIATEXT_MEDIA_ATTR = 'data-extendify-quick-edit-mediatext-media';
110 const PART_SLUG_ATTR = 'data-extendify-part-slug';
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
120 // Resolve the live DOM node for the currently-staged agent block, so the
121 // click + hover gates can carve out "inside the staged block." Returns
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.
125 const stagedBlockEl = () => {
126 const block = useQuickEditStore.getState().agentBlock;
127 if (!block?.id) return null;
128 const attr = block.target || POST_ATTR;
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;
139 };
140
141 // Resolve the committed selection's live DOM node. buildTarget stashes
142 // the element reference on the descriptor; if the block has been swapped
143 // out (e.g. by an agent workflow) we treat the commit as gone.
144 const committedBlockEl = () => {
145 const sel = useQuickEditStore.getState().committedSelection;
146 if (!sel?.el || !document.body.contains(sel.el)) return null;
147 return sel.el;
148 };
149
150 // Tries above first, falls back to below or inside. Placement is
151 // stored on the dataset so CSS can extend the bar's hover area via
152 // a ::before bridge in the right direction.
153 const positionBar = (bar, el) => {
154 const rect = el.getBoundingClientRect();
155 const bw = bar.offsetWidth;
156 const bh = bar.offsetHeight || 36;
157 const gap = 8;
158 const vw = document.documentElement.clientWidth;
159 const vh = document.documentElement.clientHeight;
160 const adminBarH = document.getElementById('wpadminbar')?.offsetHeight ?? 0;
161 const minTop = adminBarH + 4;
162
163 let left = rect.left + (rect.width - bw) / 2;
164 left = Math.max(4, Math.min(left, vw - bw - 4));
165
166 let top = rect.top - bh - gap;
167 let placement = 'above';
168 if (top < minTop) {
169 const below = rect.bottom + gap;
170 const belowFits = below + bh + 4 <= vh;
171 const visibleHeight = Math.min(rect.bottom, vh) - Math.max(rect.top, 0);
172 const dominantBlock = visibleHeight > vh * 0.7;
173 if (belowFits && !dominantBlock) {
174 top = below;
175 placement = 'below';
176 } else {
177 top = Math.max(minTop, rect.top + 4);
178 placement = 'inside';
179 }
180 }
181 bar.style.top = `${top}px`;
182 bar.style.left = `${left}px`;
183 bar.dataset.extendifyQuickEditPlacement = placement;
184 };
185
186 let translatedErrorEl = null;
187 let translatedErrorTimer = 0;
188
189 const clearTranslatedError = () => {
190 if (translatedErrorTimer) {
191 window.clearTimeout(translatedErrorTimer);
192 translatedErrorTimer = 0;
193 }
194 translatedErrorEl?.remove();
195 translatedErrorEl = null;
196 };
197
198 // Body-level notice anchored just under the hover bar, so it reads as "this
199 // block" and stays visible even with the Agent sidebar open (a top-right pill
200 // hides behind it). Inline-styled like the canvas ErrorPill — the bar lives
201 // outside the prefix-scoped stylesheet, so utility classes wouldn't reach it.
202 const showTranslatedError = (bar) => {
203 clearTranslatedError();
204 const el = document.createElement('div');
205 el.className = 'extendify-quick-edit-translated-error';
206 el.setAttribute('role', 'alert');
207 el.textContent = translatedNoticeMessage(getTranslatedContext()?.plugin);
208 const r = bar.getBoundingClientRect();
209 Object.assign(el.style, {
210 position: 'fixed',
211 zIndex: '100001',
212 maxWidth: '300px',
213 padding: '8px 12px',
214 borderRadius: '8px',
215 background: '#fee2e2',
216 color: '#991b1b',
217 fontSize: '13px',
218 lineHeight: '1.4',
219 boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
220 left: `${Math.max(4, r.left)}px`,
221 top: `${r.bottom + 6}px`,
222 });
223 document.body.appendChild(el);
224 translatedErrorEl = el;
225 translatedErrorTimer = window.setTimeout(clearTranslatedError, 6000);
226 };
227
228 const clearBar = () => {
229 if (hoverBar) {
230 hoverBar.remove();
231 hoverBar = null;
232 }
233 hoverTarget = null;
234 hideOutline();
235 clearTranslatedError();
236 };
237
238 // Walk past tagged-but-unsupported ancestors (e.g. core/post-title inside
239 // a cover's inner-container) so the bar resolves to the nearest editable
240 // parent. Without this, hovering the middle of a hero cover that
241 // surfaces post-title returned blockType=null and — combined with the
242 // template-part source gating Ask AI off — produced no bar at all.
243 const buildTarget = (el, fromEl = el) => {
244 let current = resolveTarget(el);
245 let safety = 5;
246 while (
247 current &&
248 !current.blockType &&
249 current.el?.parentElement &&
250 safety-- > 0
251 ) {
252 const next = resolveTarget(current.el.parentElement);
253 if (!next) return current;
254 current = next;
255 }
256 if (current && escapesDynamicBlock(fromEl, current.el)) {
257 return { ...current, dynamicInterior: true };
258 }
259 return current;
260 };
261
262 // Picker blocks keep the bar visible because the dropdown anchors
263 // to it; text edits tear it down so the inline toolbar can replace
264 // it. Keep aligned with PICKER_STRATEGIES in components/InlineEditor.jsx.
265 const isPickerType = (blockType) =>
266 blockType === 'core/image' ||
267 blockType === 'core/cover' ||
268 blockType === 'core/media-text:image' ||
269 blockType === 'product:image';
270
271 // Translated text blocks have no editor — Quick Edit writes the source
272 // post_content while the screen shows the translation. We never commit a
273 // selection for them (it would render nothing and the unsubSelected cancel-on-
274 // clear logic would tear down a co-staged Ask AI block); the bar's Quick Edit
275 // pill shows the error inline instead, and Ask AI stays reachable.
276 const isTranslatedTextBlock = (target) =>
277 isTranslatedRender() && isTextBearing(target?.blockType);
278
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.
282 export const pillContextFor = (target) => {
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);
289 const quickEditable =
290 quickEditEnabled &&
291 hasQuickEditModalFor(target?.blockType) &&
292 !isSyncedPatternId(compositeId);
293 const sourceKind = target?.source?.kind ?? 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'));
302 const aiAvailable =
303 isAgentAvailable() &&
304 agentSupportedSource &&
305 isAgentEligibleForTarget(target);
306 return { quickEditable, aiAvailable };
307 };
308
309 // Test seam for the Quick Edit pill's handler, which isn't exported.
310 export const editTarget = (target) => onEditClick(target);
311
312 const onEditClick = (target) => {
313 const store = useQuickEditStore.getState();
314
315 if (store.selected?.el === target.el) {
316 store.setSelected(null);
317 store.setCommittedSelection(null);
318 clearBar();
319 return;
320 }
321
322 // Translated text has no editor — leave the bar in place (its Quick Edit
323 // pill shows the error) and don't commit a selection that renders nothing
324 // and would cancel a co-staged Ask AI block on clear.
325 if (isTranslatedTextBlock(target)) return;
326
327 // Snapshot the bar rect before clearing — ImagePicker anchors to it.
328 const anchorRect = hoverBar?.getBoundingClientRect() ?? null;
329 const placement = hoverBar?.dataset.extendifyQuickEditPlacement ?? 'above';
330
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 });
335 store.setCommittedSelection(null);
336
337 if (!isPicker) {
338 track('quick_edit_action', {
339 element: target.blockType,
340 type: 'quick_edit',
341 });
342 }
343 };
344
345 const onAiClick = (el) => {
346 // clearSelected before clearBar: when QE was clicked first on a
347 // picker-type block (image / cover), `selected` is set and the
348 // InlineEditor renders an ImagePicker dropdown anchored to the bar.
349 // clearBar removes the bar but leaves the dropdown mounted as an
350 // orphan; clearing `selected` first unmounts the InlineEditor too.
351 const store = useQuickEditStore.getState();
352 store.clearSelected();
353 clearBar();
354 store.setCommittedSelection(null);
355 track('quick_edit_action', {
356 element: resolveTarget(el)?.blockType ?? null,
357 type: 'ask_ai',
358 });
359 askAiAboutElement(el);
360 };
361
362 // The Ask AI pill's handler, for callers holding an element, not a pill.
363 export const askAiTarget = (el) => onAiClick(el);
364
365 // Exported for keyboard-entry's focus-driven mount/dismiss.
366 export const showBar = (el) => renderBar(el);
367 export const hideBar = () => clearBar();
368
369 const renderBar = (el, fromEl = el) => {
370 // While an agent block is staged, the hover bar is intentionally
371 // hidden — only DOMHighlighter's X-close indicator is shown.
372 // Defense in depth for any caller (a re-render, the keyboard
373 // entry's showBar) that might otherwise paint a stale bar.
374 if (hasAgentBlockSelected()) return;
375 if (isAgentWorking()) return;
376
377 const target = buildTarget(el, fromEl);
378 const { quickEditable, aiAvailable } = pillContextFor(target);
379 // Bail BEFORE clearing the current bar — when the cursor traverses
380 // from a renderable block to an UNSUPPORTED tagged ancestor (e.g. a
381 // tagged group with too many inner tagged blocks, or a tagged
382 // post-title walked up to from inside), the previous behavior was
383 // to clearBar() first and then bail, leaving the user with no bar
384 // at all. Round-5 regression: cursor passing under the bar gap on
385 // the way to a pill could land on the ancestor before reaching the
386 // pill itself. Keep the existing bar in place if the new candidate
387 // has nothing to render.
388 if (!quickEditable && !aiAvailable) return;
389
390 clearBar();
391
392 // Position around the resolved target (which may be a walked-up
393 // ancestor), not the original DOM node we entered on. media-text's
394 // image is a child <figure> (mediaEl) of the block element — anchor the
395 // outline + bar to it so the selector hugs the image, while Quick Edit
396 // and Ask AI still act on the block element.
397 const positionEl = target?.el ?? el;
398 const anchorEl = target?.mediaEl ?? positionEl;
399 hoverTarget = anchorEl;
400 showOutline(anchorEl);
401
402 // Prefetch source markup so BlockTextEditor's load effect hits the cache.
403 // No-ops for sources the cache doesn't load (product/wpforms/nav).
404 prefetchBlockSource(target?.source, target?.blockId);
405
406 const bar = document.createElement('div');
407 bar.className = 'extendify-quick-edit extendify-quick-edit-bar';
408 bar.setAttribute('data-extendify-quick-edit-bar', '');
409 // preventDefault on mousedown so clicks don't blur a contenteditable
410 // in another open editor.
411 const stopMouseDown = (ev) => ev.preventDefault();
412 // Forward wheel events to the page scroller. The bar (and its
413 // ::before hover bridge) sits over page content; with bar
414 // pointer-events: auto, real-mouse wheel-scrolling stalled on the
415 // bar in production. An earlier attempt at a CSS-only fix
416 // (pointer-events: none on the wrapper) broke hover-
417 // traversal block→pill. Restore pointer-events: auto
418 // and own scroll in JS instead — gives us both behaviors.
419 bar.addEventListener(
420 'wheel',
421 (ev) => {
422 window.scrollBy({ left: ev.deltaX, top: ev.deltaY });
423 ev.preventDefault();
424 },
425 { passive: false },
426 );
427
428 if (quickEditable) {
429 const editBtn = document.createElement('button');
430 editBtn.type = 'button';
431 editBtn.className = 'extendify-quick-edit-pill';
432 editBtn.setAttribute('data-extendify-quick-edit-pill', '');
433 editBtn.innerHTML = '<span aria-hidden="true">✎</span>';
434 editBtn.append(__('Quick Edit', 'extendify-local'));
435 editBtn.addEventListener('mousedown', stopMouseDown);
436 editBtn.addEventListener('click', (ev) => {
437 ev.preventDefault();
438 ev.stopPropagation();
439 // Translated text can't be edited (we'd overwrite the source) — show
440 // the error right under the bar and leave the bar (with Ask AI) in
441 // place rather than opening a canvas.
442 if (isTranslatedTextBlock(target)) {
443 showTranslatedError(bar);
444 return;
445 }
446 onEditClick(target);
447 });
448 bar.appendChild(editBtn);
449 }
450
451 if (aiAvailable) {
452 const aiBtn = document.createElement('button');
453 aiBtn.type = 'button';
454 aiBtn.className = 'extendify-quick-edit-pill extendify-quick-edit-pill-ai';
455 aiBtn.setAttribute('data-extendify-quick-edit-pill', '');
456 aiBtn.innerHTML = '<span aria-hidden="true">✦</span>';
457 aiBtn.append(__('Ask AI', 'extendify-local'));
458 aiBtn.addEventListener('mousedown', stopMouseDown);
459 aiBtn.addEventListener('click', (ev) => {
460 ev.preventDefault();
461 ev.stopPropagation();
462 onAiClick(positionEl);
463 });
464 bar.appendChild(aiBtn);
465 }
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
483 document.body.appendChild(bar);
484 hoverBar = bar;
485 syncDismissState();
486 positionBar(bar, anchorEl);
487 };
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
517 // Walk up to the innermost tagged ancestor. resolveTarget then derives
518 // blockType from that element's wp-block-* class; the pill renderer
519 // decides which pills (if any) to show. Lighter than resolveTarget —
520 // onMouseOver hot path doesn't need the full descriptor.
521 const findTagged = (start) => {
522 let node = start;
523 while (node && node.nodeType === 1 && node !== document.body) {
524 if (
525 node.hasAttribute?.(POST_ATTR) ||
526 node.hasAttribute?.(PART_ATTR) ||
527 node.hasAttribute?.(PRODUCT_ATTR) ||
528 node.hasAttribute?.(WPFORM_FIELD_ATTR) ||
529 node.hasAttribute?.(MEDIATEXT_MEDIA_ATTR)
530 ) {
531 return node;
532 }
533 node = node.parentElement;
534 }
535 return null;
536 };
537
538 const onMouseOver = (e) => {
539 if (!useEditModeStore.getState().on) return;
540 if (useQuickEditStore.getState().selected) return;
541 if (isAgentWorking()) return;
542 // Sticky modes hard-suppress all hover-driven bar movement.
543 // - agentBlock staged: the bar is intentionally hidden; only
544 // DOMHighlighter's X-close is shown. To re-engage Ask AI on the
545 // same block, the user clicks X-close (clears agentBlock) then
546 // re-hovers / re-clicks.
547 // - committedSelection: the bar is pinned to the committed element.
548 // Hover anywhere else — including tagged inner blocks of a
549 // committed container — leaves the bar where it is. To select a
550 // different block, the user clicks outside or presses Esc first.
551 if (hasAgentBlockSelected()) return;
552 if (useQuickEditStore.getState().committedSelection) return;
553 if (hoverBar && (e.target === hoverBar || hoverBar.contains(e.target))) {
554 return;
555 }
556 const el = findTagged(e.target);
557 if (el === hoverTarget) return;
558 if (!el) return;
559 renderBar(el, e.target);
560 };
561
562 const onScrollOrResize = () => {
563 if (!hoverTarget) return;
564 if (hoverBar) positionBar(hoverBar, hoverTarget);
565 if (hoverOutline?.classList.contains('is-visible')) {
566 showOutline(hoverTarget, { instant: true });
567 }
568 };
569
570 // Capture-phase so we win against any underlying handler (link nav,
571 // contact form submit, theme JS) — we either commit the click as a
572 // selection, let it through, or clear the bar. Decision per `decideClickAction`.
573 // The hover bar itself is in this list so clicks on it (the pills) bail
574 // before the committed-selection clear branch fires — pill handlers run on
575 // bubble and need the bar to still be in the DOM.
576 //
577 // WP popovers (LinkControl + format-toolbar in the canvas) get explicit
578 // entries too: the popover may end up portaled inside a tagged ancestor
579 // (BlockTools' Popover.Slot lives inside our canvas, which can be a
580 // descendant of `[data-extendify-agent-block-id]`). Without these
581 // entries, the capture handler routes the click to the tagged ancestor
582 // via decideClickAction's `select` branch, preventDefault eats the
583 // click, and the URL input never focuses.
584 const QE_INTERIOR = [
585 '.extendify-quick-edit-bar',
586 '.extendify-quick-edit-canvas',
587 '.extendify-quick-edit-floating-bar',
588 '.extendify-quick-edit-image-menu',
589 '.extendify-quick-edit-modal',
590 '.extendify-quick-edit-modal-root',
591 '.block-editor-link-control',
592 '.components-popover',
593 '#extendify-agent-main',
594 '#extendify-agent-dom-mount',
595 '#wpadminbar',
596 // The wp.media library (the Agent's "Change image" picker and QE's own
597 // image flows). Without this, clicking an image in the grid reads as an
598 // outside-click: it clears the staged agentBlock and cancels the in-flight
599 // agent workflow, unmounting the picker's confirm component and orphaning
600 // the modal as a stuck white overlay.
601 '.media-modal',
602 '.media-modal-backdrop',
603 ].join(', ');
604
605 const onDocClickCapture = (e) => {
606 if (!useEditModeStore.getState().on) return;
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;
610
611 // Implicit close on the QE text-edit canvas: clicks outside the canvas
612 // while it's open save the in-flight edits instead of discarding them.
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.
616 if (hasSaver() && useQuickEditStore.getState().selected) {
617 saveSelected();
618 }
619
620 // Soft selection: while a block is staged for Ask AI, clicks INSIDE
621 // the staged block route natively (anchor navigates, form control
622 // focuses, text-content click is a no-op) — EXCEPT when they land on
623 // a tagged descendant block, in which case the same gesture swaps
624 // the stage onto the descendant (drill-in parity with the cross-
625 // sibling swap below). Clicks OUTSIDE clear the staged block —
626 // sidebar stays open. The asymmetry is intentional: closing the
627 // sidebar still cascades to clearing the block (handled in
628 // Agent.jsx), but clearing the block here does NOT close the
629 // sidebar.
630 if (hasAgentBlockSelected()) {
631 const staged = stagedBlockEl();
632 if (staged?.contains(e.target)) {
633 const innerTagged = findTagged(e.target);
634 if (!innerTagged || innerTagged === staged) return;
635 }
636 useQuickEditStore.setState({ agentBlock: null, agentBlockCode: null });
637 // Fall through to decideClickAction only when the click lands on a
638 // tagged block (sibling or descendant) — the cross-block gesture
639 // transitions both surfaces (QE + agent re-stage) in one click.
640 // Whitespace / non-tagged outside-clicks return here: the same
641 // gesture that clears the staged block shouldn't commit a new
642 // selection out of empty space.
643 if (!findTagged(e.target)) return;
644 }
645
646 // Sticky pre-pill-action selection: a prior click committed a block.
647 // Inside-clicks route natively (anchor / form control) — EXCEPT when
648 // they land on a tagged descendant, which swaps the commit onto the
649 // descendant in the same gesture. Outside-clicks clear the commit; if
650 // the same click also lands on a different tagged block, the switch
651 // below commits it in the same gesture so a single click swaps the
652 // selection. Pills bail above via QE_INTERIOR so they aren't treated
653 // as outside-clicks.
654 if (useQuickEditStore.getState().committedSelection) {
655 const committedEl = committedBlockEl();
656 if (committedEl?.contains(e.target)) {
657 const innerTagged = findTagged(e.target);
658 if (!innerTagged || innerTagged === committedEl) return;
659 }
660 useQuickEditStore.getState().setCommittedSelection(null);
661 clearBar();
662 }
663
664 const result = decideClickAction(e.target);
665 switch (result.action) {
666 case 'select': {
667 e.preventDefault();
668 e.stopPropagation();
669 const store = useQuickEditStore.getState();
670 // Only pickers reach this — other canvases cover the block they
671 // opened on.
672 if (store.selected?.el === result.el) {
673 store.clearSelected();
674 store.setCommittedSelection(null);
675 clearBar();
676 return;
677 }
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();
682 }
683 pinTarget(result.el);
684 return;
685 }
686 case 'clear':
687 clearBar();
688 return;
689 default:
690 return;
691 }
692 };
693
694 let unsubEditMode = null;
695 let unsubSelected = null;
696 let unsubAgentBlock = null;
697 let workingObserver = null;
698 let unsubCommitted = null;
699
700 export const attach = () => {
701 if (attached) return;
702 attached = true;
703 document.addEventListener('mouseover', onMouseOver, true);
704 window.addEventListener('scroll', onScrollOrResize, true);
705 window.addEventListener('resize', onScrollOrResize);
706 document.addEventListener('click', onDocClickCapture, true);
707
708 unsubEditMode = useEditModeStore.subscribe((state) => {
709 if (!state.on) {
710 useQuickEditStore.getState().setCommittedSelection(null);
711 clearBar();
712 }
713 });
714 // committedSelection → null transition: Esc / programmatic clears
715 // don't go through onDocClickCapture, so they wouldn't otherwise
716 // remove the bar. Fire clearBar here. The outside-click path
717 // already calls clearBar synchronously; this subscriber's clearBar
718 // is idempotent in that case.
719 let lastCommitted = useQuickEditStore.getState().committedSelection;
720 unsubCommitted = useQuickEditStore.subscribe((state) => {
721 const prev = lastCommitted;
722 lastCommitted = state.committedSelection;
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();
727 });
728 // Picker dropdown anchors to the bar; keep it visible for those.
729 // On the non-null → null transition (Esc / Cancel / Save closes the
730 // canvas) the bar is re-rendered on the previously edited element
731 // without waiting for a mouse-cross — mouseover only fires when the
732 // cursor crosses an element boundary, so a user who Escs without
733 // moving the cursor would otherwise see the bar disappear and stay
734 // gone until they nudged the mouse.
735 let lastSelected = useQuickEditStore.getState().selected;
736 unsubSelected = useQuickEditStore.subscribe((state) => {
737 // The store carries multiple slots (selected / committedSelection /
738 // agentBlock / dirty / error). Without this gate, an unrelated write
739 // like setCommittedSelection(null) in onEditClick would fire this
740 // listener while state.selected was still the prior non-picker
741 // block — clearBar would then tear down the bar that renderBar
742 // just mounted for the new picker target.
743 if (state.selected === lastSelected) return;
744 const prev = lastSelected;
745 lastSelected = state.selected;
746 if (state.selected) {
747 if (!isPickerType(state.selected.blockType)) clearBar();
748 return;
749 }
750 // Canvas closing (Esc / Cancel / Save / programmatic clearSelected)
751 // on the same block the agent is staged on should also clear the
752 // stage — otherwise the dashed outline + X-close indicator linger
753 // after the user dismissed the canvas. The two-pill silent-stage
754 // shape sets both slots from one click, so closing the canvas is
755 // the symmetric "I'm done with this block" gesture.
756 const agentBlock = useQuickEditStore.getState().agentBlock;
757 if (
758 prev?.blockId != null &&
759 agentBlock?.id != null &&
760 String(prev.blockId) === String(agentBlock.id)
761 ) {
762 window.dispatchEvent(new CustomEvent('extendify-agent:cancel-workflow'));
763 useQuickEditStore.getState().setAgentBlock(null);
764 }
765 if (!prev?.el || !document.body.contains(prev.el)) return;
766 if (!useEditModeStore.getState().on) return;
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;
771 renderBar(prev.el);
772 });
773 unsubAgentBlock = subscribeToAgentBlock((hasBlock) => {
774 if (hasBlock) clearBar();
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 }
790 };
791
792 export const detach = () => {
793 if (!attached) return;
794 attached = false;
795 document.removeEventListener('mouseover', onMouseOver, true);
796 window.removeEventListener('scroll', onScrollOrResize, true);
797 window.removeEventListener('resize', onScrollOrResize);
798 document.removeEventListener('click', onDocClickCapture, true);
799 unsubEditMode?.();
800 unsubSelected?.();
801 unsubAgentBlock?.();
802 workingObserver?.disconnect();
803 unsubCommitted?.();
804 unsubEditMode = null;
805 unsubSelected = null;
806 unsubAgentBlock = null;
807 workingObserver = null;
808 unsubCommitted = null;
809 clearBar();
810 removeOutline();
811 };
812