PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 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 All 128 releases
extendify / src / QuickEdit / lib / hover-bar.js

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

824 lines 30.5 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 const isAgentWaiting = () =>
121 !!document.querySelector('.wp-site-blocks.extendify-agent-waiting');
122
123 const hasAgentTask = () =>
124 !!document.querySelector('.wp-site-blocks.extendify-agent-task');
125
126 // Resolve the live DOM node for the currently-staged agent block, so the
127 // click + hover gates can carve out "inside the staged block." Returns
128 // null when no block is staged or its node has detached from the tree.
129 // Without the slug an id matches another part's block of the same number, and
130 // a click inside the staged block reads as an outside-click.
131 const stagedBlockEl = () => {
132 const block = useQuickEditStore.getState().agentBlock;
133 if (!block?.id) return null;
134 const attr = block.target || POST_ATTR;
135 const slug = block.source?.partSlug || null;
136 const matches = [
137 ...document.querySelectorAll(`[${attr}="${CSS.escape(String(block.id))}"]`),
138 ];
139 const inScope = matches.filter(
140 (el) =>
141 (el.closest(`[${PART_SLUG_ATTR}]`)?.getAttribute(PART_SLUG_ATTR) ??
142 null) === slug,
143 );
144 return inScope[0] ?? matches[0] ?? null;
145 };
146
147 // Resolve the committed selection's live DOM node. buildTarget stashes
148 // the element reference on the descriptor; if the block has been swapped
149 // out (e.g. by an agent workflow) we treat the commit as gone.
150 const committedBlockEl = () => {
151 const sel = useQuickEditStore.getState().committedSelection;
152 if (!sel?.el || !document.body.contains(sel.el)) return null;
153 return sel.el;
154 };
155
156 // Tries above first, falls back to below or inside. Placement is
157 // stored on the dataset so CSS can extend the bar's hover area via
158 // a ::before bridge in the right direction.
159 const positionBar = (bar, el) => {
160 const rect = el.getBoundingClientRect();
161 const bw = bar.offsetWidth;
162 const bh = bar.offsetHeight || 36;
163 const gap = 8;
164 const vw = document.documentElement.clientWidth;
165 const vh = document.documentElement.clientHeight;
166 const adminBarH = document.getElementById('wpadminbar')?.offsetHeight ?? 0;
167 const minTop = adminBarH + 4;
168
169 let left = rect.left + (rect.width - bw) / 2;
170 left = Math.max(4, Math.min(left, vw - bw - 4));
171
172 let top = rect.top - bh - gap;
173 let placement = 'above';
174 if (top < minTop) {
175 const below = rect.bottom + gap;
176 const belowFits = below + bh + 4 <= vh;
177 const visibleHeight = Math.min(rect.bottom, vh) - Math.max(rect.top, 0);
178 const dominantBlock = visibleHeight > vh * 0.7;
179 if (belowFits && !dominantBlock) {
180 top = below;
181 placement = 'below';
182 } else {
183 top = Math.max(minTop, rect.top + 4);
184 placement = 'inside';
185 }
186 }
187 bar.style.top = `${top}px`;
188 bar.style.left = `${left}px`;
189 bar.dataset.extendifyQuickEditPlacement = placement;
190 };
191
192 let translatedErrorEl = null;
193 let translatedErrorTimer = 0;
194
195 const clearTranslatedError = () => {
196 if (translatedErrorTimer) {
197 window.clearTimeout(translatedErrorTimer);
198 translatedErrorTimer = 0;
199 }
200 translatedErrorEl?.remove();
201 translatedErrorEl = null;
202 };
203
204 // Body-level notice anchored just under the hover bar, so it reads as "this
205 // block" and stays visible even with the Agent sidebar open (a top-right pill
206 // hides behind it). Inline-styled like the canvas ErrorPill — the bar lives
207 // outside the prefix-scoped stylesheet, so utility classes wouldn't reach it.
208 const showTranslatedError = (bar) => {
209 clearTranslatedError();
210 const el = document.createElement('div');
211 el.className = 'extendify-quick-edit-translated-error';
212 el.setAttribute('role', 'alert');
213 el.textContent = translatedNoticeMessage(getTranslatedContext()?.plugin);
214 const r = bar.getBoundingClientRect();
215 Object.assign(el.style, {
216 position: 'fixed',
217 zIndex: '100001',
218 maxWidth: '300px',
219 padding: '8px 12px',
220 borderRadius: '8px',
221 background: '#fee2e2',
222 color: '#991b1b',
223 fontSize: '13px',
224 lineHeight: '1.4',
225 boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
226 left: `${Math.max(4, r.left)}px`,
227 top: `${r.bottom + 6}px`,
228 });
229 document.body.appendChild(el);
230 translatedErrorEl = el;
231 translatedErrorTimer = window.setTimeout(clearTranslatedError, 6000);
232 };
233
234 const clearBar = () => {
235 if (hoverBar) {
236 hoverBar.remove();
237 hoverBar = null;
238 }
239 hoverTarget = null;
240 hideOutline();
241 clearTranslatedError();
242 };
243
244 // Walk past tagged-but-unsupported ancestors (e.g. core/post-title inside
245 // a cover's inner-container) so the bar resolves to the nearest editable
246 // parent. Without this, hovering the middle of a hero cover that
247 // surfaces post-title returned blockType=null and — combined with the
248 // template-part source gating Ask AI off — produced no bar at all.
249 const buildTarget = (el, fromEl = el) => {
250 let current = resolveTarget(el);
251 let safety = 5;
252 while (
253 current &&
254 !current.blockType &&
255 current.el?.parentElement &&
256 safety-- > 0
257 ) {
258 const next = resolveTarget(current.el.parentElement);
259 if (!next) return current;
260 current = next;
261 }
262 if (current && escapesDynamicBlock(fromEl, current.el)) {
263 return { ...current, dynamicInterior: true };
264 }
265 return current;
266 };
267
268 // Picker blocks keep the bar visible because the dropdown anchors
269 // to it; text edits tear it down so the inline toolbar can replace
270 // it. Keep aligned with PICKER_STRATEGIES in components/InlineEditor.jsx.
271 const isPickerType = (blockType) =>
272 blockType === 'core/image' ||
273 blockType === 'core/cover' ||
274 blockType === 'core/media-text:image' ||
275 blockType === 'product:image';
276
277 // Translated text blocks have no editor — Quick Edit writes the source
278 // post_content while the screen shows the translation. We never commit a
279 // selection for them (it would render nothing and the unsubSelected cancel-on-
280 // clear logic would tear down a co-staged Ask AI block); the bar's Quick Edit
281 // pill shows the error inline instead, and Ask AI stays reachable.
282 const isTranslatedTextBlock = (target) =>
283 isTranslatedRender() && isTextBearing(target?.blockType);
284
285 // Which pills a target would surface (without mounting the bar). A target
286 // with no pills isn't worth pinning. Exported so keyboard-entry gates Enter
287 // on the same signal the hover bar uses.
288 export const pillContextFor = (target) => {
289 const quickEditEnabled = !!window.extQuickEditData?.quickEditEnabled;
290 // A synced pattern is addressed off whichever tagger stamped it, so both
291 // id spaces have to be checked or the pill returns on pages.
292 const compositeId =
293 target?.el?.getAttribute?.(PART_ATTR) ??
294 target?.el?.getAttribute?.(POST_ATTR);
295 // An open editor blocks sending, so it would stall the agent's task.
296 const quickEditable =
297 quickEditEnabled &&
298 !hasAgentTask() &&
299 hasQuickEditModalFor(target?.blockType) &&
300 !isSyncedPatternId(compositeId);
301 const sourceKind = target?.source?.kind ?? null;
302 // A ref-nav item routes Quick Edit's save through wp_navigation, but the
303 // agent reaches it through the id the part tagger stamped.
304 const agentSupportedSource =
305 sourceKind === 'post' ||
306 sourceKind === 'template-part' ||
307 sourceKind === null ||
308 (sourceKind === 'wp-navigation' &&
309 !!target?.el?.getAttribute?.('data-extendify-part-block-id'));
310 const aiAvailable =
311 isAgentAvailable() &&
312 agentSupportedSource &&
313 isAgentEligibleForTarget(target);
314 return { quickEditable, aiAvailable };
315 };
316
317 // Test seam for the Quick Edit pill's handler, which isn't exported.
318 export const editTarget = (target) => onEditClick(target);
319
320 const onEditClick = (target) => {
321 const store = useQuickEditStore.getState();
322
323 if (store.selected?.el === target.el) {
324 store.setSelected(null);
325 store.setCommittedSelection(null);
326 clearBar();
327 return;
328 }
329
330 // Translated text has no editor — leave the bar in place (its Quick Edit
331 // pill shows the error) and don't commit a selection that renders nothing
332 // and would cancel a co-staged Ask AI block on clear.
333 if (isTranslatedTextBlock(target)) return;
334
335 // Snapshot the bar rect before clearing — ImagePicker anchors to it.
336 const anchorRect = hoverBar?.getBoundingClientRect() ?? null;
337 const placement = hoverBar?.dataset.extendifyQuickEditPlacement ?? 'above';
338
339 const isPicker = isPickerType(target.blockType);
340 if (!isPicker) clearBar();
341 // Order matters: clearing the commit first drops a picker's anchor bar.
342 store.setSelected({ ...target, anchorRect, anchorPlacement: placement });
343 store.setCommittedSelection(null);
344
345 if (!isPicker) {
346 track('quick_edit_action', {
347 element: target.blockType,
348 type: 'quick_edit',
349 });
350 }
351 };
352
353 const onAiClick = (el) => {
354 // clearSelected before clearBar: when QE was clicked first on a
355 // picker-type block (image / cover), `selected` is set and the
356 // InlineEditor renders an ImagePicker dropdown anchored to the bar.
357 // clearBar removes the bar but leaves the dropdown mounted as an
358 // orphan; clearing `selected` first unmounts the InlineEditor too.
359 const store = useQuickEditStore.getState();
360 store.clearSelected();
361 clearBar();
362 store.setCommittedSelection(null);
363 track('quick_edit_action', {
364 element: resolveTarget(el)?.blockType ?? null,
365 type: 'ask_ai',
366 });
367 askAiAboutElement(el);
368 };
369
370 // The Ask AI pill's handler, for callers holding an element, not a pill.
371 export const askAiTarget = (el) => onAiClick(el);
372
373 // Exported for keyboard-entry's focus-driven mount/dismiss.
374 export const showBar = (el) => renderBar(el);
375 export const hideBar = () => clearBar();
376
377 const renderBar = (el, fromEl = el) => {
378 // While an agent block is staged, the hover bar is intentionally
379 // hidden — only DOMHighlighter's X-close indicator is shown.
380 // Defense in depth for any caller (a re-render, the keyboard
381 // entry's showBar) that might otherwise paint a stale bar.
382 if (hasAgentBlockSelected()) return;
383 if (isAgentWorking()) return;
384
385 const target = buildTarget(el, fromEl);
386 const { quickEditable, aiAvailable } = pillContextFor(target);
387 // Bail BEFORE clearing the current bar — when the cursor traverses
388 // from a renderable block to an UNSUPPORTED tagged ancestor (e.g. a
389 // tagged group with too many inner tagged blocks, or a tagged
390 // post-title walked up to from inside), the previous behavior was
391 // to clearBar() first and then bail, leaving the user with no bar
392 // at all. Round-5 regression: cursor passing under the bar gap on
393 // the way to a pill could land on the ancestor before reaching the
394 // pill itself. Keep the existing bar in place if the new candidate
395 // has nothing to render.
396 if (!quickEditable && !aiAvailable) return;
397
398 clearBar();
399
400 // Position around the resolved target (which may be a walked-up
401 // ancestor), not the original DOM node we entered on. media-text's
402 // image is a child <figure> (mediaEl) of the block element — anchor the
403 // outline + bar to it so the selector hugs the image, while Quick Edit
404 // and Ask AI still act on the block element.
405 const positionEl = target?.el ?? el;
406 const anchorEl = target?.mediaEl ?? positionEl;
407 hoverTarget = anchorEl;
408 showOutline(anchorEl);
409
410 // Prefetch source markup so BlockTextEditor's load effect hits the cache.
411 // No-ops for sources the cache doesn't load (product/wpforms/nav).
412 prefetchBlockSource(target?.source, target?.blockId);
413
414 const bar = document.createElement('div');
415 bar.className = 'extendify-quick-edit extendify-quick-edit-bar';
416 bar.setAttribute('data-extendify-quick-edit-bar', '');
417 // preventDefault on mousedown so clicks don't blur a contenteditable
418 // in another open editor.
419 const stopMouseDown = (ev) => ev.preventDefault();
420 // Forward wheel events to the page scroller. The bar (and its
421 // ::before hover bridge) sits over page content; with bar
422 // pointer-events: auto, real-mouse wheel-scrolling stalled on the
423 // bar in production. An earlier attempt at a CSS-only fix
424 // (pointer-events: none on the wrapper) broke hover-
425 // traversal block→pill. Restore pointer-events: auto
426 // and own scroll in JS instead — gives us both behaviors.
427 bar.addEventListener(
428 'wheel',
429 (ev) => {
430 window.scrollBy({ left: ev.deltaX, top: ev.deltaY });
431 ev.preventDefault();
432 },
433 { passive: false },
434 );
435
436 if (quickEditable) {
437 const editBtn = document.createElement('button');
438 editBtn.type = 'button';
439 editBtn.className = 'extendify-quick-edit-pill';
440 editBtn.setAttribute('data-extendify-quick-edit-pill', '');
441 editBtn.innerHTML = '<span aria-hidden="true">✎</span>';
442 editBtn.append(__('Quick Edit', 'extendify-local'));
443 editBtn.addEventListener('mousedown', stopMouseDown);
444 editBtn.addEventListener('click', (ev) => {
445 ev.preventDefault();
446 ev.stopPropagation();
447 // Translated text can't be edited (we'd overwrite the source) — show
448 // the error right under the bar and leave the bar (with Ask AI) in
449 // place rather than opening a canvas.
450 if (isTranslatedTextBlock(target)) {
451 showTranslatedError(bar);
452 return;
453 }
454 onEditClick(target);
455 });
456 bar.appendChild(editBtn);
457 }
458
459 if (aiAvailable) {
460 const aiBtn = document.createElement('button');
461 aiBtn.type = 'button';
462 aiBtn.className = 'extendify-quick-edit-pill extendify-quick-edit-pill-ai';
463 aiBtn.setAttribute('data-extendify-quick-edit-pill', '');
464 aiBtn.innerHTML = '<span aria-hidden="true">✦</span>';
465 // translators: Button on a page block that hands it to the AI agent, which asked the user to pick one.
466 const shareLabel = __('Share with agent', 'extendify-local');
467 aiBtn.append(
468 isAgentWaiting() ? shareLabel : __('Ask AI', 'extendify-local'),
469 );
470 aiBtn.addEventListener('mousedown', stopMouseDown);
471 aiBtn.addEventListener('click', (ev) => {
472 ev.preventDefault();
473 ev.stopPropagation();
474 onAiClick(positionEl);
475 });
476 bar.appendChild(aiBtn);
477 }
478
479 const closeBtn = document.createElement('button');
480 closeBtn.type = 'button';
481 closeBtn.className =
482 'extendify-quick-edit-pill extendify-quick-edit-pill-close';
483 closeBtn.setAttribute('data-extendify-quick-edit-pill', '');
484 closeBtn.setAttribute('aria-label', __('Dismiss', 'extendify-local'));
485 closeBtn.innerHTML = '<span aria-hidden="true">✕</span>';
486 closeBtn.addEventListener('mousedown', stopMouseDown);
487 closeBtn.addEventListener('click', (ev) => {
488 ev.preventDefault();
489 ev.stopPropagation();
490 useQuickEditStore.getState().setCommittedSelection(null);
491 clearBar();
492 });
493 bar.appendChild(closeBtn);
494
495 document.body.appendChild(bar);
496 hoverBar = bar;
497 syncDismissState();
498 positionBar(bar, anchorEl);
499 };
500
501 // Nothing to dismiss until a click pins the bar; hover ends with the pointer.
502 const syncDismissState = () => {
503 const closeBtn = hoverBar?.querySelector('.extendify-quick-edit-pill-close');
504 if (!closeBtn) return;
505 closeBtn.disabled = !useQuickEditStore.getState().committedSelection;
506 };
507
508 // preventScroll: the pill is already on screen; scrolling to it jumps the page.
509 const focusFirstPill = () => {
510 hoverBar
511 ?.querySelector('.extendify-quick-edit-pill')
512 ?.focus({ preventScroll: true });
513 };
514
515 export const pinTarget = (el, target = buildTarget(el)) => {
516 const { quickEditable, aiAvailable } = pillContextFor(target);
517 const store = useQuickEditStore.getState();
518 if (!quickEditable && !aiAvailable) {
519 store.setCommittedSelection(null);
520 clearBar();
521 return;
522 }
523 renderBar(el);
524 store.setCommittedSelection(target);
525 syncDismissState();
526 focusFirstPill();
527 };
528
529 // Walk up to the innermost tagged ancestor. resolveTarget then derives
530 // blockType from that element's wp-block-* class; the pill renderer
531 // decides which pills (if any) to show. Lighter than resolveTarget —
532 // onMouseOver hot path doesn't need the full descriptor.
533 const findTagged = (start) => {
534 let node = start;
535 while (node && node.nodeType === 1 && node !== document.body) {
536 if (
537 node.hasAttribute?.(POST_ATTR) ||
538 node.hasAttribute?.(PART_ATTR) ||
539 node.hasAttribute?.(PRODUCT_ATTR) ||
540 node.hasAttribute?.(WPFORM_FIELD_ATTR) ||
541 node.hasAttribute?.(MEDIATEXT_MEDIA_ATTR)
542 ) {
543 return node;
544 }
545 node = node.parentElement;
546 }
547 return null;
548 };
549
550 const onMouseOver = (e) => {
551 if (!useEditModeStore.getState().on) return;
552 if (useQuickEditStore.getState().selected) return;
553 if (isAgentWorking()) return;
554 // Sticky modes hard-suppress all hover-driven bar movement.
555 // - agentBlock staged: the bar is intentionally hidden; only
556 // DOMHighlighter's X-close is shown. To re-engage Ask AI on the
557 // same block, the user clicks X-close (clears agentBlock) then
558 // re-hovers / re-clicks.
559 // - committedSelection: the bar is pinned to the committed element.
560 // Hover anywhere else — including tagged inner blocks of a
561 // committed container — leaves the bar where it is. To select a
562 // different block, the user clicks outside or presses Esc first.
563 if (hasAgentBlockSelected()) return;
564 if (useQuickEditStore.getState().committedSelection) return;
565 if (hoverBar && (e.target === hoverBar || hoverBar.contains(e.target))) {
566 return;
567 }
568 const el = findTagged(e.target);
569 if (el === hoverTarget) return;
570 if (!el) return;
571 renderBar(el, e.target);
572 };
573
574 const onScrollOrResize = () => {
575 if (!hoverTarget) return;
576 if (hoverBar) positionBar(hoverBar, hoverTarget);
577 if (hoverOutline?.classList.contains('is-visible')) {
578 showOutline(hoverTarget, { instant: true });
579 }
580 };
581
582 // Capture-phase so we win against any underlying handler (link nav,
583 // contact form submit, theme JS) — we either commit the click as a
584 // selection, let it through, or clear the bar. Decision per `decideClickAction`.
585 // The hover bar itself is in this list so clicks on it (the pills) bail
586 // before the committed-selection clear branch fires — pill handlers run on
587 // bubble and need the bar to still be in the DOM.
588 //
589 // WP popovers (LinkControl + format-toolbar in the canvas) get explicit
590 // entries too: the popover may end up portaled inside a tagged ancestor
591 // (BlockTools' Popover.Slot lives inside our canvas, which can be a
592 // descendant of `[data-extendify-agent-block-id]`). Without these
593 // entries, the capture handler routes the click to the tagged ancestor
594 // via decideClickAction's `select` branch, preventDefault eats the
595 // click, and the URL input never focuses.
596 const QE_INTERIOR = [
597 '.extendify-quick-edit-bar',
598 '.extendify-quick-edit-canvas',
599 '.extendify-quick-edit-floating-bar',
600 '.extendify-quick-edit-image-menu',
601 '.extendify-quick-edit-modal',
602 '.extendify-quick-edit-modal-root',
603 '.block-editor-link-control',
604 '.components-popover',
605 '#extendify-agent-main',
606 '#extendify-agent-dom-mount',
607 '#wpadminbar',
608 // The wp.media library (the Agent's "Change image" picker and QE's own
609 // image flows). Without this, clicking an image in the grid reads as an
610 // outside-click: it clears the staged agentBlock and cancels the in-flight
611 // agent workflow, unmounting the picker's confirm component and orphaning
612 // the modal as a stuck white overlay.
613 '.media-modal',
614 '.media-modal-backdrop',
615 ].join(', ');
616
617 const onDocClickCapture = (e) => {
618 if (!useEditModeStore.getState().on) return;
619 if (e.target?.closest?.(QE_INTERIOR)) return;
620 // Below QE_INTERIOR, or a run also deadens the sidebar and canvas.
621 if (isAgentWorking()) return;
622
623 // Implicit close on the QE text-edit canvas: clicks outside the canvas
624 // while it's open save the in-flight edits instead of discarding them.
625 // `hasSaver()` is false for picker blocks (image / cover) — they save
626 // synchronously on pick and never register. Fall through either way so
627 // the existing agentBlock-clear + clear-bar branches still run.
628 if (hasSaver() && useQuickEditStore.getState().selected) {
629 saveSelected();
630 }
631
632 // Soft selection: while a block is staged for Ask AI, clicks INSIDE
633 // the staged block route natively (anchor navigates, form control
634 // focuses, text-content click is a no-op) — EXCEPT when they land on
635 // a tagged descendant block, in which case the same gesture swaps
636 // the stage onto the descendant (drill-in parity with the cross-
637 // sibling swap below). Clicks OUTSIDE clear the staged block —
638 // sidebar stays open. The asymmetry is intentional: closing the
639 // sidebar still cascades to clearing the block (handled in
640 // Agent.jsx), but clearing the block here does NOT close the
641 // sidebar.
642 if (hasAgentBlockSelected()) {
643 const staged = stagedBlockEl();
644 if (staged?.contains(e.target)) {
645 const innerTagged = findTagged(e.target);
646 if (!innerTagged || innerTagged === staged) return;
647 }
648 useQuickEditStore.setState({ agentBlock: null, agentBlockCode: null });
649 // Fall through to decideClickAction only when the click lands on a
650 // tagged block (sibling or descendant) — the cross-block gesture
651 // transitions both surfaces (QE + agent re-stage) in one click.
652 // Whitespace / non-tagged outside-clicks return here: the same
653 // gesture that clears the staged block shouldn't commit a new
654 // selection out of empty space.
655 if (!findTagged(e.target)) return;
656 }
657
658 // Sticky pre-pill-action selection: a prior click committed a block.
659 // Inside-clicks route natively (anchor / form control) — EXCEPT when
660 // they land on a tagged descendant, which swaps the commit onto the
661 // descendant in the same gesture. Outside-clicks clear the commit; if
662 // the same click also lands on a different tagged block, the switch
663 // below commits it in the same gesture so a single click swaps the
664 // selection. Pills bail above via QE_INTERIOR so they aren't treated
665 // as outside-clicks.
666 if (useQuickEditStore.getState().committedSelection) {
667 const committedEl = committedBlockEl();
668 if (committedEl?.contains(e.target)) {
669 const innerTagged = findTagged(e.target);
670 if (!innerTagged || innerTagged === committedEl) return;
671 }
672 useQuickEditStore.getState().setCommittedSelection(null);
673 clearBar();
674 }
675
676 const result = decideClickAction(e.target);
677 switch (result.action) {
678 case 'select': {
679 e.preventDefault();
680 e.stopPropagation();
681 const store = useQuickEditStore.getState();
682 // Only pickers reach this — other canvases cover the block they
683 // opened on.
684 if (store.selected?.el === result.el) {
685 store.clearSelected();
686 store.setCommittedSelection(null);
687 clearBar();
688 return;
689 }
690 // stopPropagation above blocks ImagePicker's bubble-phase
691 // outside-click — without this clear its menu lingers (issue 19).
692 if (store.selected && isPickerType(store.selected.blockType)) {
693 store.clearSelected();
694 }
695 pinTarget(result.el);
696 return;
697 }
698 case 'clear':
699 clearBar();
700 return;
701 default:
702 return;
703 }
704 };
705
706 let unsubEditMode = null;
707 let unsubSelected = null;
708 let unsubAgentBlock = null;
709 let workingObserver = null;
710 let unsubCommitted = null;
711
712 export const attach = () => {
713 if (attached) return;
714 attached = true;
715 document.addEventListener('mouseover', onMouseOver, true);
716 window.addEventListener('scroll', onScrollOrResize, true);
717 window.addEventListener('resize', onScrollOrResize);
718 document.addEventListener('click', onDocClickCapture, true);
719
720 unsubEditMode = useEditModeStore.subscribe((state) => {
721 if (!state.on) {
722 useQuickEditStore.getState().setCommittedSelection(null);
723 clearBar();
724 }
725 });
726 // committedSelection → null transition: Esc / programmatic clears
727 // don't go through onDocClickCapture, so they wouldn't otherwise
728 // remove the bar. Fire clearBar here. The outside-click path
729 // already calls clearBar synchronously; this subscriber's clearBar
730 // is idempotent in that case.
731 let lastCommitted = useQuickEditStore.getState().committedSelection;
732 unsubCommitted = useQuickEditStore.subscribe((state) => {
733 const prev = lastCommitted;
734 lastCommitted = state.committedSelection;
735 if (!prev || state.committedSelection) return;
736 // A picker dropdown anchors to the bar; clearing here orphans it.
737 if (isPickerType(state.selected?.blockType)) return;
738 clearBar();
739 });
740 // Picker dropdown anchors to the bar; keep it visible for those.
741 // On the non-null → null transition (Esc / Cancel / Save closes the
742 // canvas) the bar is re-rendered on the previously edited element
743 // without waiting for a mouse-cross — mouseover only fires when the
744 // cursor crosses an element boundary, so a user who Escs without
745 // moving the cursor would otherwise see the bar disappear and stay
746 // gone until they nudged the mouse.
747 let lastSelected = useQuickEditStore.getState().selected;
748 unsubSelected = useQuickEditStore.subscribe((state) => {
749 // The store carries multiple slots (selected / committedSelection /
750 // agentBlock / dirty / error). Without this gate, an unrelated write
751 // like setCommittedSelection(null) in onEditClick would fire this
752 // listener while state.selected was still the prior non-picker
753 // block — clearBar would then tear down the bar that renderBar
754 // just mounted for the new picker target.
755 if (state.selected === lastSelected) return;
756 const prev = lastSelected;
757 lastSelected = state.selected;
758 if (state.selected) {
759 if (!isPickerType(state.selected.blockType)) clearBar();
760 return;
761 }
762 // Canvas closing (Esc / Cancel / Save / programmatic clearSelected)
763 // on the same block the agent is staged on should also clear the
764 // stage — otherwise the dashed outline + X-close indicator linger
765 // after the user dismissed the canvas. The two-pill silent-stage
766 // shape sets both slots from one click, so closing the canvas is
767 // the symmetric "I'm done with this block" gesture.
768 const agentBlock = useQuickEditStore.getState().agentBlock;
769 if (
770 prev?.blockId != null &&
771 agentBlock?.id != null &&
772 String(prev.blockId) === String(agentBlock.id)
773 ) {
774 window.dispatchEvent(new CustomEvent('extendify-agent:cancel-workflow'));
775 useQuickEditStore.getState().setAgentBlock(null);
776 }
777 if (!prev?.el || !document.body.contains(prev.el)) return;
778 if (!useEditModeStore.getState().on) return;
779 if (hoverTarget === prev.el && hoverBar) return;
780 // A save resolves after a newer pin; re-rendering here steals its bar.
781 const committed = useQuickEditStore.getState().committedSelection;
782 if (committed?.el && committed.el !== prev.el) return;
783 renderBar(prev.el);
784 });
785 unsubAgentBlock = subscribeToAgentBlock((hasBlock) => {
786 if (hasBlock) clearBar();
787 });
788 // A bar mounted before the run began stays clickable otherwise.
789 workingObserver = new MutationObserver(() => {
790 if (!isAgentWorking()) return;
791 // Otherwise hover stays suppressed after the workflow ends.
792 useQuickEditStore.getState().setCommittedSelection(null);
793 clearBar();
794 });
795 const root = document.querySelector('.wp-site-blocks');
796 if (root) {
797 workingObserver.observe(root, {
798 attributes: true,
799 attributeFilter: ['class'],
800 });
801 }
802 };
803
804 export const detach = () => {
805 if (!attached) return;
806 attached = false;
807 document.removeEventListener('mouseover', onMouseOver, true);
808 window.removeEventListener('scroll', onScrollOrResize, true);
809 window.removeEventListener('resize', onScrollOrResize);
810 document.removeEventListener('click', onDocClickCapture, true);
811 unsubEditMode?.();
812 unsubSelected?.();
813 unsubAgentBlock?.();
814 workingObserver?.disconnect();
815 unsubCommitted?.();
816 unsubEditMode = null;
817 unsubSelected = null;
818 unsubAgentBlock = null;
819 workingObserver = null;
820 unsubCommitted = null;
821 clearBar();
822 removeOutline();
823 };
824